Architecture Patterns

Interview Context

資深 DS / MLE 面試越來越常碰到架構題:你的 feature pipeline 要不要拆成獨立 service?實驗事件怎麼留 audit trail?分析讀模型跟交易庫怎麼分離?這頁整理面試最常出現的 architecture patterns — 重點不是背名詞,而是能講清楚每個 pattern 解決什麼問題、付出什麼代價、什麼時候不該用。

What You Should Understand

  • 能比較 monolith、modular monolith、microservices 的 tradeoffs,並解釋為什麼「先從 modular monolith 開始」是主流建議
  • 知道 microservices 的核心紀律:database per service、API contract、observability,以及 distributed monolith 這個反模式
  • 能區分 event vs command、choreography vs orchestration,並用 saga 和 outbox pattern 處理分散式交易
  • 理解 event sourcing 與 CRUD 的差異、CQRS 的讀寫分離,以及兩者搭配時的 eventual consistency
  • 熟悉 DDD 關鍵術語(bounded context、aggregate、ubiquitous language)與 resilience patterns(circuit breaker、bulkhead、retry budget)

Monolith vs Modular Monolith vs Microservices

Three ways to structure a system:

  • Monolith: single deployable unit, single codebase, usually single database. All modules call each other in-process.
  • Modular Monolith: still one deployable unit, but the codebase is split into modules with explicit boundaries — each module owns its tables and exposes an internal API; other modules cannot reach into its internals.
  • Microservices: each service is independently deployable, owns its own data store, and communicates over the network (REST/gRPC/events).
DimensionMonolithModular MonolithMicroservices
Deploy unitOne artifactOne artifactMany independent services
Team scalingPoor — everyone touches everythingGood — teams own modulesBest — teams own services end-to-end
Operational complexityLowLowHigh — network, discovery, tracing, deploys
Data ownershipShared databaseModule-owned tables, one physical DBDatabase per service
Failure modeWhole app downWhole app downPartial failure, cascading failure risk
Refactoring boundariesEasy(in-process)EasyHard — network API changes need coordination
Latency between componentsNanoseconds(function call)NanosecondsMilliseconds(network hop)

Start with a Modular Monolith

「先從 modular monolith 開始」的邏輯:

  1. 邊界一開始一定畫錯。系統早期你還不了解 domain,module 邊界會一直重畫。在 monolith 裡搬 code 是一次 refactor;在 microservices 裡搬邊界是跨 service 的 API migration + data migration,成本高一個數量級。
  2. Microservices 的成本是固定支出。service discovery、distributed tracing、CI/CD per service、on-call — 不管你有沒有 scale 問題都要付。
  3. Modular monolith 保留退路。如果 module 邊界乾淨(own tables、explicit interface),之後要把某個 module 拆成獨立 service,只需要把 in-process call 換成 network call。

When Microservices Pay Off

SignalWhy It Justifies the Cost
Independent scaling needsInference service 需要 GPU + autoscale,CRUD API 不需要
Team autonomy bottleneck多個 team 在同一個 codebase 上互相 block deploy
Different tech requirements一個 component 需要 Python + PyTorch,其他是 JVM
Independent release cadenceModel service 每天 deploy,billing service 每月一次
Fault isolation推薦掛掉不能拖垮結帳流程

常見面試陷阱

被問「monolith 好還是 microservices 好」時,直接選邊站是弱回答。強回答是:這是 organizational 和 operational 的 tradeoff,不是技術優劣 — team 小、domain 不確定時 modular monolith 幾乎總是對的起點;microservices 是在 scaling(人或機器)出現明確瓶頸時才值得付的成本。

Microservices Best Practices

If you do go with microservices, these practices separate a working system from a distributed mess:

PracticeWhat It MeansWhy
Database per serviceEach service owns its data store; others access via API only共用 DB 讓 schema 變成隱形耦合 — 改一張表要協調所有 service
Explicit API contractsVersioned schemas(OpenAPI, protobuf), backward compatible changesConsumer 不會因 provider 改欄位而在 runtime 爆炸
Service discoveryServices find each other via registry / DNS, not hardcoded IPsInstances 隨 autoscaling 動態增減
Async where possibleEvents for non-critical paths instead of sync call chains減少 latency 疊加與 cascading failure
Idempotent handlersSame message processed twice gives same resultMessage queue 是 at-least-once delivery,重複必然發生
Independent CI/CDEach service builds, tests, deploys alone一起 deploy 就失去拆分的意義

Observability: Logging, Metrics, Tracing

Distributed systems 的 debug 靠三根柱子:

PillarQuestion It AnswersTooling Examples
LoggingWhat happened in this service?Structured JSON logs + centralized store(ELK)
MetricsIs the system healthy overall?(rates, latency percentiles)Prometheus + Grafana
TracingWhere did this one request spend its time across services?OpenTelemetry, Jaeger

關鍵設計:每個 request 進入系統時產生一個 correlation ID(trace ID),跟著 request 穿過所有 service 和 message queue。沒有它,跨 service debug 幾乎不可能。

Anti-pattern: The Distributed Monolith

最糟的結果是 distributed monolith — 拆成很多 services,但保留了 monolith 的耦合:

  • Services 必須一起 deploy(API 沒版本控制,改一個要全改)
  • Services 共用同一個 database(schema 耦合)
  • 一個 request 觸發長串同步呼叫鏈 A → B → C → D,任何一環掛掉全部失敗
  • 結果:付了 microservices 的 operational cost(network、tracing、多套 deploy),卻沒拿到任何 independence 的好處

面試必考的反模式

Distributed monolith 是架構面試的高頻考點。判斷標準一句話:「如果你不能獨立 deploy 一個 service 而不協調其他 team,你就沒有 microservices — 你只有一個更難 debug 的 monolith。」

Event-Driven Architecture

In event-driven architecture (EDA), services communicate by publishing and consuming events instead of calling each other directly.

Events vs Commands

AspectEventCommand
SemanticsSomething happened(past tense fact)Do something(imperative request)
NamingOrderPlaced, ModelTrainedPlaceOrder, TrainModel
AudienceZero or many subscribers — publisher doesn't know whoExactly one handler
CouplingPublisher 不依賴 consumerSender 依賴 receiver 存在且成功
Failure expectationConsumers 各自處理,publisher 不管Sender 通常在乎結果(成功/失敗)

直覺:event 是「廣播事實」,command 是「指定某人做事」。用 event 的系統天生鬆耦合 — 新增一個 consumer(例如多接一個 analytics pipeline)不需要改 publisher。

Choreography vs Orchestration

跨多個 service 的流程有兩種協調方式:

AspectChoreographyOrchestration
Control沒有中央控制 — 每個 service 聽 event 決定自己做什麼一個 orchestrator 明確呼叫每一步
Coupling最鬆 — services 只認識 event schemaOrchestrator 認識所有 participants
Visibility差 — 流程散落在各 service 的 event handlers好 — 流程集中一處,容易讀懂與監控
Adding steps加 consumer 即可,不改別人改 orchestrator
Debugging難(誰觸發了誰?)較易(orchestrator 有完整 state)
Best for簡單、步驟少、天然廣播型流程長流程、需要補償邏輯、需要清楚狀態機

經驗法則:3 步以內、失敗處理簡單 → choreography;長流程、要處理各種失敗分支 → orchestration(用 workflow engine 例如 Temporal、Airflow 的角色類似)。

Saga Pattern: Distributed Transactions

Microservices 中沒有跨 service 的 ACID transaction(database per service)。Saga 把一個分散式交易拆成一串 local transactions,每一步都定義對應的 compensating action(補償動作):

T1T2T3on failure at T3:C2C1T_1 \to T_2 \to T_3 \quad \text{on failure at } T_3: \quad C_2 \to C_1
  • 每個 TiT_i 是某個 service 的 local transaction(自己 DB 內 ACID)
  • 若第 ii 步失敗,依序執行前面步驟的補償 Ci1,,C1C_{i-1}, \dots, C_1(例如「扣款」的補償是「退款」)
  • Saga 可以用 choreography(每個 service 聽前一步的 event)或 orchestration(saga orchestrator 統一調度)實作

注意:saga 給的不是 isolation — 中間狀態對外可見(例如訂單短暫顯示「已成立」後又取消)。設計時要接受 eventual consistency 並讓補償語意對業務合理。

Outbox Pattern

經典問題:service 要「寫 DB」+「發 event」兩件事,但這兩個動作跨了 database 和 message broker,無法包在同一個 transaction — 可能 DB 寫成功但 event 沒發出(或反過來),造成資料不一致。

Transactional outbox 的解法:

  1. 同一個 DB transaction 裡,寫 business row 一筆 event 到 outbox table(同一個 DB,所以 atomic)
  2. 一個獨立的 relay process(polling 或 CDC 例如 Debezium)讀 outbox table,把 event 發到 broker,成功後標記已發送
  3. Relay 是 at-least-once — consumer 端必須 idempotent(用 event ID 去重)

這保證「state change 與 event 要嘛都發生、要嘛都不發生」,是 event-driven 系統的基礎建設。

Event Sourcing vs CRUD

Two Ways to Store State

  • CRUD(state-as-snapshot): database 存「目前狀態」。update 就地覆寫,歷史預設消失。
  • Event Sourcing(state-as-log): database 存「發生過的所有事件」的 append-only log。目前狀態不直接存,而是把事件從頭 replay 計算出來(可加 snapshot 加速)。

直覺:CRUD 像只記「帳戶餘額 = 320」;event sourcing 像銀行對帳單 — 記每筆存提款,餘額是加總出來的。對帳單永遠可以重算餘額,反之不行。

DimensionCRUDEvent Sourcing
What is storedCurrent state(rows overwritten)Immutable append-only event log
History / auditLost unless you add audit tablesComplete by construction
Read current stateDirect query(fast, simple)Replay events or read projection
Time travel / replayImpossibleRebuild state as of any point
Debugging production issues只看得到結果可以重播出 bug 發生的完整過程
Schema evolutionMigrate tablesMust version events; old events live forever
ComplexityLow — every ORM does thisHigh — projections, snapshots, event versioning
StorageCompactGrows without bound(需要 snapshot / archiving)

When Event Sourcing Is Worth It

  • Audit 是硬需求:金融交易、醫療紀錄、實驗平台的 assignment 紀錄 — 「誰在什麼時候做了什麼」本身就是產品需求
  • 需要 retroactive computation:新指標上線後,想對歷史資料重算(replay events 進新的 projection)
  • Debug 與 reproduce:把 production 的事件流重播到 staging 重現 bug

When It Is Not

  • 一般 CRUD app(user profile、設定頁):event sourcing 的複雜度(event versioning、projection lag、學習成本)遠超過收益
  • Team 沒有 event-driven 經驗:這是全隊的 mental model 轉換,不是一個 library

和 ML 的連結

ML 系統其實早就在用類似概念:training data 的 immutable snapshot、feature 的 point-in-time correctness、experiment event log — 都是「不可變事實 + 由事實推導狀態」的思路。面試時能把 event sourcing 連到 feature store 的 point-in-time join,是很強的訊號。

CQRS

CQRS (Command Query Responsibility Segregation) separates the write model from the read model:

  • Command side(write): 處理狀態變更,模型為了「正確性」設計 — normalized schema、business invariants、transaction
  • Query side(read): 服務查詢,模型為了「讀取效率」設計 — denormalized views、預先 join、甚至不同的資料庫(例如 write 用 PostgreSQL,read 用 Elasticsearch)

兩邊透過 events 同步:write side 發出 change events,一個 projector 消費 events 更新 read model。

When CQRS Helps

SituationWhy CQRS Fits
Read-heavy analytics viewsDashboard 查詢複雜(多表 join、聚合),直接打交易庫會拖垮 write path — 用 denormalized read model 預先算好
Read/write scale asymmetry讀比寫多 100 倍 → read model 可以獨立 scale、加 cache、加 replica
Different query shapes同一份資料要支援 full-text search、時間序列、圖查詢 → 各自 project 到適合的 store
Pairing with event sourcingEvent log 本身難以查詢 — CQRS 的 projections 就是 event sourcing 的標配讀取層

Eventual Consistency Implications

Read model 是非同步更新的,所以有 replication lag

  • 使用者剛送出更新,馬上刷新頁面可能看到舊資料(read-your-own-writes 問題)
  • 緩解手段:UI optimistic update、寫入後短暫改讀 write model、或在 response 帶 version 讓 read 端等到追上
  • 面試重點:CQRS 不是 free lunch — 你用「同步一致性」換「讀寫各自最佳化」。如果業務不能接受 stale reads(例如扣款餘額檢查),該查詢就必須走 write model。

CQRS 不等於 Event Sourcing

兩者常一起出現但彼此獨立:你可以只做 CQRS(write DB + 物化出來的 read views,用 CDC 同步)而完全不做 event sourcing;也可以 event sourcing 而只有一個 read model。面試時混用這兩個詞是常見扣分點。

Domain-Driven Design Key Terms

DDD (Domain-Driven Design) 的核心主張:軟體的模組邊界應該跟著業務領域的邊界走,而不是技術分層。面試常考名詞定義與應用:

TermMeaningExample
Bounded Context一個 model 有效的邊界 — 同一個詞在不同 context 意義不同,各自建模「Model」在 Training context 是 artifact + hyperparameters;在 Serving context 是 endpoint + version
Ubiquitous LanguageTeam 與 domain experts 共用的精確詞彙,直接反映在 code 命名Code 裡叫 ExperimentVariantExposure,跟 PM 開會用同一套詞
Aggregate一組必須一起維持 invariant 的 objects,有一個 root 作為唯一入口,transaction 邊界Order aggregate 包含 line items — 總金額 invariant 由 root 保證
Entity有唯一 identity、生命週期中屬性會變的物件User(改 email 還是同一個 user)
Value Object沒有 identity、由屬性值定義、immutableMoney(100, "TWD")、DateRange — 值相等即相等
Domain EventDomain 中發生的、業務在乎的事實ExperimentStarted、ModelPromoted
Anti-corruption Layer在自己 context 與外部系統之間的翻譯層,避免外部 model 污染內部把 legacy CRM 的欄位翻譯成自己 context 的概念再使用

DDD 與 microservices 的關係:bounded context 是最自然的 service 邊界。拆 service 拆錯的常見原因就是照技術分層拆(例如「API service」「DB service」),而不是照 domain 拆。

Clean Architecture and SOLID

The Dependency Rule

Clean Architecture organizes code in concentric layers. The rule: dependencies point inward only — inner layers never know about outer layers.

Layer(inner → outer)ContainsKnows About
EntitiesCore business rules, domain objectsNothing outside itself
Use CasesApplication-specific workflowsEntities
Interface AdaptersControllers, presenters, gatewaysUse cases
Frameworks & DriversWeb framework, DB, external APIsAdapters

直覺:業務邏輯是最穩定、最值錢的部分,不該依賴最常換的東西(framework、DB)。內層透過 interface(port) 定義它需要什麼,外層提供實作(adapter)— 這就是 dependency inversion。

SOLID in One Line Each

PrincipleOne-liner
S — Single Responsibility一個 class 只有一個改變的理由
O — Open/Closed對擴充開放,對修改封閉 — 加功能用新增,不用改舊 code
L — Liskov Substitution子類別可以無痛替換父類別,不改變正確性
I — Interface Segregation多個小 interface 勝過一個肥 interface — client 不該被迫依賴用不到的方法
D — Dependency Inversion依賴抽象而非實作 — 高層模組不依賴低層細節

Why This Matters for ML Code

ML codebase 最常見的病:training script 直接 import BigQuery client、硬編 GCS path、model 邏輯跟 orchestrator 綁死。套用 dependency rule 的實際好處:

  • 可測試FeatureRepository 是 interface → unit test 塞 in-memory fake,不用連 warehouse
  • 可搬移:換 data warehouse / serving framework 只改 adapter,core pipeline 不動
  • 可重用:同一份 feature transform 邏輯,batch(Spark adapter)和 online(service adapter)共用 — 這正是消除 train-serving skew 的結構性解法

Resilience Patterns

Distributed systems 的元件一定會壞 — resilience patterns 的目標是「局部失敗不變成整體失敗」。

PatternProblem It SolvesHow It Works
Timeout慢的依賴把你的 threads 全部卡死每個外部呼叫都設上限;沒有 timeout 的呼叫是定時炸彈
Retry with backoff + jitter暫時性錯誤(network blip)重試 + exponential backoff + 隨機 jitter,避免同時重試造成 thundering herd
Retry budgetRetry 本身放大流量、壓垮已經在掙扎的下游限制 retry 佔總流量的比例(例如 10%);超過就直接失敗
Circuit breaker對已經掛掉的依賴持續打流量,拖慢自己也妨礙對方恢復錯誤率超過門檻 → open(直接 fail fast)→ 一段時間後 half-open 放少量流量試探 → 成功則 close
Bulkhead一個依賴的故障耗盡共用資源(thread pool、connection pool)每個依賴用隔離的資源池 — 像船艙隔板,一艙進水不沉全船
Graceful degradation依賴完全不可用時,全功能失敗準備 fallback:推薦服務掛了 → 回傳熱門榜;feature store timeout → 用 default feature values

Circuit Breaker States

Closedfailure rate above thresholdOpencooldown elapsedHalf-Openprobe succeedsClosed\text{Closed} \xrightarrow{\text{failure rate above threshold}} \text{Open} \xrightarrow{\text{cooldown elapsed}} \text{Half-Open} \xrightarrow{\text{probe succeeds}} \text{Closed}

ML Serving 的 Degradation 階梯

面試講 ML serving resilience 時,準備一個 fallback 階梯會非常加分:full model → cached predictions → simpler/smaller model → popularity baseline → static default。每往下一階犧牲一點品質換可用性。重點:fallback 要在平時就演練,且 monitoring 要能看到「現在有多少流量走在 fallback 上」。

Real-World Use Cases

Case 1: ML Platform — 從 Monolith 拆出 Feature 與 Serving Service

你們的 ML platform 一開始是一個 Django monolith:資料標註、feature 計算、training 觸發、模型 serving 全在同一個 codebase、同一個 PostgreSQL。隨著模型數量成長,出現兩個明確訊號:(1)serving 的流量是其他功能的 200 倍,每次全站 deploy 都造成 inference 短暫抖動;(2)serving 需要 GPU node 和獨立 autoscaling,但整個 monolith 只能一起 scale。

套用本頁概念的決策過程:

  • 先確認 monolith 內部是否已經 modular — serving module 是否只透過明確 interface 拿 feature?如果不是,先在 monolith 內把邊界整理乾淨(modular monolith),否則拆出去就是 distributed monolith
  • bounded context 拆:serving context(低延遲、高可用)與 feature context(batch 計算、正確性優先)的需求本質不同,是自然的 service 邊界
  • 拆出後遵守 database per service:serving 用自己的 online store(Redis),不再直接讀 platform 的 PostgreSQL
  • 為 serving 加上 timeout + circuit breaker + fallback(feature 拿不到就用 default values),避免 feature store 抖動打掛 inference

Interview follow-ups:

  • 你怎麼判斷「現在」是拆的時機,而不是再等等?(獨立 scaling / deploy cadence / fault isolation 三個訊號)
  • 拆出去之後,training 和 serving 的 feature 邏輯怎麼保持一致?(shared transform library 或 feature store — 連回 train-serving skew)
  • 如果拆完發現兩個 service 每次都要一起 deploy,代表什麼?(邊界畫錯,distributed monolith 警訊)

Case 2: 實驗平台 — 用 Event Sourcing 做 Assignment 的 Audit Trail

你負責公司的 A/B testing 平台。法務與 data governance 要求:任何時間點都要能回答「user X 在時間 T 屬於哪個 variant、是被哪條規則分進去的」。原本的 CRUD 設計只存「user 目前的 assignment」,一旦 experiment 改配置或 rollback,歷史就被覆寫 — 事後根本無法重建。

改用 event sourcing 的設計:

  • 把每個動作記成 immutable event:ExperimentCreated、TrafficAllocationChanged、UserAssigned、UserExcluded、ExperimentStopped
  • 「user 目前的 assignment」變成 replay events 得出的 projection,另外物化一份供低延遲查詢
  • Audit 需求 by construction 滿足:replay 到時間 T 就能重建當時的完整狀態
  • 額外紅利:發現 metrics 異常時,可以把 production 的事件流 replay 到 staging,重現 assignment bug 發生的過程;新的分析需求(例如重算 exposure 定義)直接對歷史 events 跑新 projection

Interview follow-ups:

  • Event log 無限成長怎麼辦?(snapshot + 冷資料 archive;replay 從最近 snapshot 開始)
  • Event schema 要改版怎麼處理?(events immutable — 用 versioned event types + upcaster,舊事件永遠要能讀)
  • 為什麼不乾脆在 CRUD 上加一張 audit table 就好?(audit table 是事後補記、容易漏寫且與 state 可能不一致;event sourcing 中 events 就是 source of truth,不可能不一致)

Case 3: 交易系統 — 用 CQRS 分離交易庫與分析讀模型

你是電商公司的 DS,BI dashboard 和你的 churn model 的 feature query 都直接打訂單交易庫(normalized PostgreSQL)。旺季時分析查詢的大 join 把交易庫 CPU 吃滿,checkout latency 飆高 — 分析工作負載正在傷害賺錢的 write path。

CQRS 式的重構:

  • Write model 不動:訂單服務繼續用 normalized schema 保證交易正確性
  • 訂單服務透過 outbox pattern 可靠地發出 OrderPlaced、OrderRefunded 等 events(避免「DB 寫了但 event 沒發」的不一致)
  • Projector 消費 events,維護 denormalized read models:BI 用的寬表(訂單 + 用戶 + 商品預先 join)、你的 feature 用的每日聚合表
  • 分析與 feature 查詢全部改打 read model — 交易庫只服務交易
  • 接受 eventual consistency:read model 落後秒級。對 BI 和 daily feature 完全無感;但「下單後即時防詐檢查」這種不能容忍 stale 的查詢,仍然走 write path

Interview follow-ups:

  • Read model 壞掉或 projector 有 bug 怎麼辦?(read model 是 derived data — 修好 projector 後從 event log / CDC 重建即可,這是 CQRS 的大優點)
  • 怎麼監控 read model 的 lag?(consumer lag metric + 資料端的 freshness check,lag 超標要告警)
  • 這個架構跟直接用 read replica 差在哪?(replica 是同 schema 的複本,只解流量不解 query shape;CQRS 的 read model 可以是完全不同的 denormalized 結構甚至不同 DB)

Hands-on: Architecture Patterns in Python

Outbox Pattern: Atomic Write of State and Event

import json
import uuid
import sqlite3

def place_order(conn: sqlite3.Connection, user_id: str, amount: float):
    """Write the business row AND the outbox event in ONE transaction."""
    order_id = str(uuid.uuid4())
    event = {
        "event_id": str(uuid.uuid4()),
        "type": "OrderPlaced",
        "payload": {"order_id": order_id, "user_id": user_id, "amount": amount},
    }
    with conn:  # single transaction: both rows commit or neither does
        conn.execute(
            "INSERT INTO orders (id, user_id, amount, status) VALUES (?, ?, ?, ?)",
            (order_id, user_id, amount, "PLACED"),
        )
        conn.execute(
            "INSERT INTO outbox (event_id, type, payload, published) VALUES (?, ?, ?, 0)",
            (event["event_id"], event["type"], json.dumps(event["payload"])),
        )
    return order_id

def relay_outbox(conn: sqlite3.Connection, broker):
    """Separate relay process: poll unpublished events, push to broker."""
    rows = conn.execute(
        "SELECT event_id, type, payload FROM outbox WHERE published = 0 ORDER BY rowid"
    ).fetchall()
    for event_id, event_type, payload in rows:
        broker.publish(event_type, payload)  # at-least-once delivery
        conn.execute("UPDATE outbox SET published = 1 WHERE event_id = ?", (event_id,))
        conn.commit()
    # consumers must deduplicate by event_id (idempotency)

Event Sourcing: Apply and Replay

from dataclasses import dataclass, field

@dataclass
class Account:
    """State is DERIVED from events, never stored directly."""
    account_id: str
    balance: float = 0.0
    events: list = field(default_factory=list)  # append-only log

    def apply(self, event: dict):
        """Pure state transition — no side effects, deterministic."""
        if event["type"] == "Deposited":
            self.balance += event["amount"]
        elif event["type"] == "Withdrawn":
            self.balance -= event["amount"]

    def deposit(self, amount: float):
        event = {"type": "Deposited", "amount": amount}
        self.events.append(event)  # record the fact first
        self.apply(event)          # then update in-memory state

    def withdraw(self, amount: float):
        if amount > self.balance:  # invariant checked by the aggregate root
            raise ValueError("insufficient funds")
        event = {"type": "Withdrawn", "amount": amount}
        self.events.append(event)
        self.apply(event)

    @classmethod
    def replay(cls, account_id: str, events: list) -> "Account":
        """Rebuild current state from the full event history."""
        account = cls(account_id)
        for event in events:
            account.apply(event)
        account.events = list(events)
        return account

# replay gives time travel: rebuild state as of any prefix of the log
# history = load_events("acct-1")
# state_now  = Account.replay("acct-1", history)
# state_then = Account.replay("acct-1", history[:10])

CQRS: Projecting Events into a Read Model

from collections import defaultdict

class OrderStatsProjection:
    """Read model optimized for analytics queries.
    Updated asynchronously by consuming write-side events."""

    def __init__(self):
        self.revenue_by_user = defaultdict(float)   # denormalized view 1
        self.order_count_by_user = defaultdict(int) # denormalized view 2
        self.seen_event_ids = set()                 # idempotency guard

    def handle(self, event: dict):
        if event["event_id"] in self.seen_event_ids:
            return  # duplicate delivery — at-least-once broker
        self.seen_event_ids.add(event["event_id"])

        if event["type"] == "OrderPlaced":
            self.revenue_by_user[event["user_id"]] += event["amount"]
            self.order_count_by_user[event["user_id"]] += 1
        elif event["type"] == "OrderRefunded":
            self.revenue_by_user[event["user_id"]] -= event["amount"]

    def top_spenders(self, k: int = 10):
        """Query answered from the read model — write DB never touched."""
        ranked = sorted(self.revenue_by_user.items(), key=lambda x: -x[1])
        return ranked[:k]

# rebuild is cheap: drop the projection, replay all events through handle()

Interview Signals

What interviewers listen for:

  • 你把 monolith vs microservices 講成 organizational/operational tradeoff,而不是技術優劣,並主動提出「先 modular monolith」的演進路線
  • 你能點名 distributed monolith 的判斷標準:不能獨立 deploy、共用 DB、長同步呼叫鏈
  • 你分得清 event vs command、choreography vs orchestration,並知道 outbox + idempotent consumer 是 event-driven 的可靠性基礎
  • 你不會把 CQRS 和 event sourcing 混為一談,且能講清楚 eventual consistency 對產品的實際影響(read-your-own-writes)
  • 你能把架構概念連回 ML 場景:bounded context 對應 training/serving 拆分、dependency inversion 消除 train-serving skew、degradation 階梯保護 inference

Practice

Flashcards

Flashcards (1/10)

為什麼主流建議是「先從 modular monolith 開始」而不是直接上 microservices?

(1)早期 domain 不熟,module 邊界一定會重畫 — monolith 內搬 code 是一次 refactor,跨 service 搬是昂貴的 API + data migration。(2)Microservices 的 operational cost(discovery、tracing、多套 CI/CD)是固定支出。(3)Modular monolith 邊界乾淨的話,之後拆 service 只是把 in-process call 換成 network call。

Click card to flip

Quiz

Question 1/10

團隊 5 人、產品剛起步、domain 還在快速變動。最合理的架構起點是?

Mark as Complete

3/5 — Okay