System Design · Draft

18 — Architecture styles

Chapter 17 compared a monolith with microservices. Those are two points on a wider map. This chapter covers the other styles that come up in practice, what each one costs, and how a system moves from one to another.

An architecture style is a set of answers to three questions:

QuestionExamples of answers
What is the unit of deployment?One application, a few services, many services, single functions
How do the parts communicate?In-process calls, synchronous requests, messages on a broker, events
Where does data live, and who owns it?One shared database, a schema per module, a database per service, an event log

Most real systems mix styles. A modular monolith can publish events, and a microservice estate can run some of its work as serverless functions. The useful skill is to know what each answer costs.

The modular monolith

A modular monolith is one deployable application whose code is split into modules with enforced boundaries. Each module owns a business capability, exposes a small public interface, and keeps everything else private.

app/
  billing/      api.py   (public)   internal/...   tables: billing.*
  catalog/      api.py   (public)   internal/...   tables: catalog.*
  orders/       api.py   (public)   internal/...   tables: orders.*
  shared/       logging, config, auth helpers (no business logic)

The rules that make it modular rather than just tidy:

  • A module calls another only through that module’s public interface, never its internals.
  • A module owns its tables. No other module reads or writes them directly, and there are no joins across module boundaries.
  • Dependencies between modules point one way, without cycles.
  • The rules are checked at build time, not left to review. Import-checking tools exist for most languages and fail the build when a module imports another’s internals.

PostgreSQL can enforce the data rule with one schema and one role per module:

CREATE SCHEMA billing;
CREATE ROLE billing_svc LOGIN;
GRANT USAGE ON SCHEMA billing TO billing_svc;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing TO billing_svc;
-- orders_svc gets no grant on billing, so a cross-module query fails.

If each module connects with its own role, a stray join from orders into billing.invoices fails with a permission error instead of quietly coupling the two.

What the modular monolith keeps from a plain monolith: one build, one deployment, in-process calls measured in nanoseconds rather than milliseconds, one place to debug, and the option of a single database transaction. What it adds: boundaries that make the code easier to reason about, and a clean seam if a module later needs to become a service. Extracting a module whose tables and interface are already separate is mostly a matter of putting a network call behind the interface.

The temptation to watch is the single transaction. If orders and billing update their tables in one transaction, extracting either later means replacing that transaction with an outbox or a saga from chapter 15. Keeping cross-module workflows asynchronous from the start, even in-process, keeps that door open.

Martin Fowler’s Monolith First note makes the case for starting here. It observes that almost all the successful microservice stories he heard of started with a monolith that grew too big and was split, and that systems built as microservices from scratch often ended in serious trouble. The underlying reason is that good boundaries are hard to guess before the domain is understood, and moving a boundary inside one codebase is far cheaper than moving it between services.

Service-oriented architecture

Service-oriented architecture (SOA) predates microservices. It came from large enterprises trying to connect many existing applications, such as billing, inventory and customer records, by exposing each as a reusable service.

A typical SOA had a central enterprise service bus (ESB). Services connected to the bus, and the bus handled routing, message transformation, protocol conversion and often orchestration of business processes. Contracts were commonly SOAP and WSDL, with a shared canonical data model that every service mapped to.

Microservices share the idea of services with network interfaces, but they differ in where the intelligence sits and who decides.

SOA (typical)Microservices (typical)
ScopeThe whole enterprise, integrating many applicationsOne application or product, split into services
CommunicationThrough a central bus that routes and transformsDirect HTTP or gRPC, or a plain message broker
Where logic sitsMuch of it in the bus: routing, mapping, orchestrationIn the services. The pipes only carry messages
DataServices often share databasesEach service owns its data
Data modelOne canonical model for everyoneEach service has its own model, translated at the edges
GovernanceCentral architecture group, shared standardsEach team decides within light platform rules
Main goalReuse of shared servicesIndependent change and deployment

The table describes tendencies, not definitions. Both terms are used loosely, and many systems called SOA look like microservices, and the reverse.

The lessons from SOA carry over. A central bus that holds business logic becomes a bottleneck that every team must change through, the same risk as a gateway that accumulates logic (chapter 17). A canonical data model sounds efficient, but every change to it touches every service. Reuse is a weaker goal than it seems, because a shared service has to satisfy all its callers, and so it changes slowly.

Event-driven architecture

In an event-driven system, components announce that something happened, and other components react. The sender doesn’t know who listens.

An event states a fact in the past tense: OrderPlaced, PaymentFailed. A command asks for something to happen: ChargeCard. The difference matters. A command has one intended handler and can be refused. An event has any number of listeners and can’t be refused, because it already happened.

Martin Fowler’s article What do you mean by “Event-Driven”? separates four patterns that share the name: event notification, event-carried state transfer, event sourcing and CQRS. The first three describe what an event carries and what it’s for.

Notification, state transfer and sourcing

Event notification says that something changed and carries little more than an ID. A consumer that needs details calls the source back.

{"type": "CustomerAddressChanged", "customer_id": "c-81", "occurred_at": "2026-11-09T10:00:00Z"}

Event-carried state transfer puts the new state in the event, so consumers can keep their own copy and never call back.

{"type": "CustomerAddressChanged", "customer_id": "c-81", "occurred_at": "2026-11-09T10:00:00Z",
 "address": {"line1": "1 Main St", "city": "Austin", "postal_code": "78701", "country": "US"}}

Event sourcing makes the events the system of record. Current state is derived by replaying them. It’s covered in more depth below.

Event notificationEvent-carried state transferEvent sourcing
Event carriesAn ID and a typeThe changed dataEvery change, as the source of truth
Consumer calls back?Usually yesNoNot applicable: the log is the data
Load on the sourceGrows with consumersIndependent of consumersWrites only
Consumer availabilityDepends on the source being upWorks when the source is downNot applicable
Data freshnessFresh at call-back timeEventually consistent copyThe log is authoritative; views lag
Main costHidden call-back trafficCopies of data everywhere, schema contractsReplay, versioning, deletion

Event-carried state transfer is how a service can keep working when its dependencies are down. The price is that the event schema is now a public contract, and every consumer holds a copy of data it doesn’t own.

Fowler also describes a trap: an event used as a passive-aggressive command. If OrderPlaced is sent because the sender needs the warehouse to ship, and the order is broken if it doesn’t, that’s a command with an event’s name. Naming it honestly, and handling refusal, makes the dependency visible.

Choreography and orchestration

Chapter 15 introduced both for sagas. They apply to any multi-step flow.

Choreography                              Orchestration

Orders --OrderPlaced--> Payments           Orchestrator --ChargeCard--> Payments
Payments --PaymentTaken--> Inventory       Orchestrator --Reserve-----> Inventory
Inventory --Reserved--> Shipping           Orchestrator --Ship--------> Shipping
(no single place shows the flow)           (the flow is code in one place)
ChoreographyOrchestration
Where the flow is definedSpread across the consumersIn one orchestrator
Adding a stepAdd a consumer; others don’t changeChange the orchestrator
Changing the order of stepsChange several servicesChange one place
Seeing a stuck flowReconstruct it from logs and tracesQuery the orchestrator’s state
CouplingConsumers depend on event schemasOrchestrator depends on every step’s API
Failure handlingEach service handles its own compensationCentral retries, timeouts and compensation

Choreography fits loose reactions where no one owns the end-to-end outcome, such as updating a search index or sending an analytics event. Orchestration fits business processes with an owner, a deadline and compensation, such as order fulfillment. Many systems use orchestration inside a business process and choreography between processes.

Tracing and debugging events

Asynchronous flows are hard to follow because there’s no call stack. A question like “why didn’t order 512 ship?” has to be answered from what each component recorded. Four habits make that possible.

Put tracing fields in every event envelope.

{
  "event_id": "e-7f3a",
  "type": "PaymentTaken",
  "occurred_at": "2026-11-09T10:00:02Z",
  "correlation_id": "order-512",
  "causation_id": "e-51c0",
  "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "schema_version": 2,
  "data": {"order_id": "order-512", "amount_cents": 4999}
}

The correlation ID is shared by every event in one business flow. The causation ID is the ID of the event that caused this one, so the events form a tree that can be rebuilt. The traceparent field carries W3C Trace Context across the broker, as described in chapter 20, so a tracing system can show the producer and consumer spans in one trace.

Log every receive and every publish with those IDs. A query on the correlation ID then gives the full timeline across services.

Measure consumer lag. The gap between the newest event and the last one a consumer processed is the first thing to check when something is late. Chapter 8 covers lag and dead-letter queues.

Keep events replayable. A log that retains events, as Kafka does, lets a fixed consumer reprocess a period. That only works if consumers are idempotent (chapter 15).

Schemas need care too. Adding an optional field is safe. Removing or renaming a field breaks consumers the producer may not know exist. A schema registry that rejects incompatible changes at publish time catches this before it reaches production.

Serverless

Serverless here means functions as a service: code that runs in response to an event, on machines the provider manages, billed by use. AWS Lambda is the reference example, and the figures below come from its documentation.

The execution model

Lambda runs each function in an execution environment, an isolated sandbox. The environment lifecycle has three phases:

  1. Init. Lambda downloads the code, starts the runtime and runs initialization code outside the handler. This phase is limited to 10 seconds.
  2. Invoke. Lambda runs the handler for one request.
  3. Shutdown. Eventually, an idle environment is removed.

After an invocation, the environment is frozen and kept for a while. A new request for the same function can reuse it, which is a warm start. Work done during Init, such as opening a database connection or loading a model, is reused by every warm request.

Each environment handles one request at a time. Lambda’s concurrency documentation puts it as a formula:

concurrency=requests per second×average duration in seconds\text{concurrency} = \text{requests per second} \times \text{average duration in seconds}

The documentation’s own example: 5,000 requests a second at 200 ms each needs a concurrency of 1,000 environments.

That has a design consequence. Each environment holds its own connections. A function at a concurrency of 1,000 can open 1,000 PostgreSQL connections, which is far beyond what a PostgreSQL server is normally configured for. Functions that talk to PostgreSQL usually go through a connection pooler.

Cold starts

A cold start happens when no idle environment is available, so a request waits for Init. The documentation says cold starts typically occur in under 1% of invocations, and last from under 100 ms to over 1 second. The time is billed. They’re more common for rarely invoked functions, and after scaling up.

Ways to reduce them:

  • Keep the deployment package and its imports small, because Init loads them.
  • Do expensive setup once, outside the handler.
  • Use provisioned concurrency, which keeps a set number of environments initialized, at a cost whether or not they serve requests.
  • Use SnapStart where supported, which restores environments from a snapshot taken after Init.

Limits

From the Lambda quotas page and the scaling documentation, fetched 2026-11-09:

LimitValue
Function timeout900 seconds (15 minutes)
Memory128 MB to 10,240 MB, in 1 MB steps
CPUProportional to memory; 1,769 MB is the equivalent of one vCPU
Synchronous payload6 MB request and 6 MB response
Asynchronous payload1 MB
/tmp storage512 MB to 10,240 MB
Deployment package50 MB zipped upload, 250 MB unzipped
Concurrent executions1,000 per Region by default, can be raised
Scaling rate1,000 new environments every 10 seconds, per function
Init phase10 seconds

Requests beyond the concurrency quota or the scaling rate are throttled with a 429. New accounts start with lower quotas. Longer limits exist for some newer Lambda variants, but the figures above are the standard ones.

The 15-minute limit rules out long jobs unless they’re split into steps. The 6 MB payload rules out large uploads through the function itself; the usual pattern is to upload to object storage and let the function process the object. The concurrency quota is shared by every function in the account and Region, so one runaway function can throttle the others. Reserved concurrency caps a function and guarantees it a share.

Cost against an always-on server

Lambda bills per request and per GB-second of configured memory multiplied by duration. An always-on instance bills per hour whether it’s busy or not. The comparison below uses prices fetched on 2026-11-09 for US East (N. Virginia), on-demand, ignoring the free tier:

REQ_PRICE = 0.20 / 1_000_000      # $ per request
GB_S_PRICE = 0.0000166667         # $ per GB-second, x86, first tier
EC2_HOURLY = 0.1008               # m7i.large, Linux, us-east-1
HOURS = 730                       # hours in an average month
SECONDS = HOURS * 3600

def lambda_monthly(rps, ms, mem_gb):
    n = rps * SECONDS
    return n * REQ_PRICE + n * (ms / 1000) * mem_gb * GB_S_PRICE

def crossover_rps(ms, mem_gb, servers):
    per_req = REQ_PRICE + (ms / 1000) * mem_gb * GB_S_PRICE
    return servers * EC2_HOURLY * HOURS / (per_req * SECONDS)

One m7i.large for a month costs $73.58. Lambda’s monthly cost at a steady average rate:

Average requests a second512 MB, 50 ms1 GB, 200 ms
1$1.62$9.29
5$8.10$46.43
10$16.21$92.86
50$81.03$464.28
100$162.06$928.56
500$810.30$4,642.81

Break-even points:

FunctionAgainst 1 serverAgainst 2 servers
512 MB, 50 ms45.4 requests a second90.8 requests a second
1 GB, 200 ms7.9 requests a second15.8 requests a second

The numbers say less than they seem to, for several reasons:

  • The server count is an assumption. Whether one m7i.large can serve 45 requests a second depends entirely on the work. If each request spends 50 ms waiting on a database, a server can overlap thousands of them. Lambda bills that waiting time anyway, because duration is wall-clock time.
  • Peaks matter more than averages. Servers are sized for peak load plus headroom, and usually at least two for redundancy. A workload that averages 5 requests a second but bursts to 500 is cheap on Lambda and needs many idle servers otherwise.
  • Prices move. Arm functions and instances are cheaper, commitment discounts apply to both, and the free tier helps small workloads.
  • Operating cost isn’t in the table. Patching, scaling and capacity planning for servers cost engineering time. Lambda shifts some of that to the provider.

The general shape holds: serverless is cheapest for spiky, low or unpredictable traffic, and always-on servers become cheaper as traffic becomes steady and high. Each workload needs its own calculation with its own measured duration.

Where serverless fits

Good fitPoor fit
Event handlers: file uploads, queue messages, webhooksSteady high-throughput APIs
Spiky or rare traffic, scheduled jobsLatency-critical paths where cold starts hurt
Glue between managed servicesLong jobs beyond the timeout
Small teams without operations capacityWorkloads that need large local state or many database connections

CQRS and event sourcing in depth

Chapter 15 introduced both. This section covers how they’re built.

The event store

An event store is an append-only log split into streams, usually one per aggregate, such as one bank account. Each event has a position, its version, within its stream. The core operation is a conditional append: write these events only if the stream is still at the version the writer last read. That’s optimistic concurrency, and it replaces row locks.

In PostgreSQL, a unique constraint does the work:

CREATE TABLE events (
    global_position bigserial PRIMARY KEY,
    stream_id       text        NOT NULL,
    version         integer     NOT NULL,
    type            text        NOT NULL,
    data            jsonb       NOT NULL,
    occurred_at     timestamptz NOT NULL DEFAULT now(),
    UNIQUE (stream_id, version)
);

-- The writer read the stream at version 3, so its next event is version 4.
INSERT INTO events (stream_id, version, type, data)
VALUES ('acct-1', 4, 'Withdrew', '{"amount": 50}');
-- A concurrent writer that also read version 3 gets a unique violation and retries.

The global_position column gives projections a single order to read in. In PostgreSQL a bigserial value is assigned when the row is inserted, but transactions can commit out of order, so a reader that tracks “the last position I processed” can skip a row that commits late. Event stores built on PostgreSQL handle this, for example by having readers stay a short distance behind the newest committed position, or by assigning positions in a single writer.

A runnable example with snapshots

The program below keeps an event-sourced account, enforces a business rule on the write side, rejects a concurrent write, maintains a read model, and compares loading a stream of a million events with and without a snapshot.

import time
from dataclasses import dataclass, field

class ConcurrencyError(Exception):
    pass

@dataclass(frozen=True)
class Event:
    stream: str
    version: int          # position within the stream, starting at 1
    kind: str             # "Opened", "Deposited", "Withdrew"
    amount: int = 0

@dataclass
class EventStore:
    events: dict = field(default_factory=dict)      # stream -> list[Event]
    snapshots: dict = field(default_factory=dict)   # stream -> (version, state)
    subscribers: list = field(default_factory=list)

    def append(self, stream, expected_version, new_events):
        log = self.events.setdefault(stream, [])
        if len(log) != expected_version:
            raise ConcurrencyError(f"{stream}: expected v{expected_version}, at v{len(log)}")
        for i, (kind, amount) in enumerate(new_events, start=expected_version + 1):
            e = Event(stream, i, kind, amount)
            log.append(e)
            for s in self.subscribers:
                s(e)

    def read(self, stream, after_version=0):
        return self.events.get(stream, [])[after_version:]

@dataclass
class Account:
    balance: int = 0
    is_open: bool = False
    version: int = 0

    def apply(self, e):
        if e.kind == "Opened":
            self.is_open = True
        elif e.kind == "Deposited":
            self.balance += e.amount
        elif e.kind == "Withdrew":
            self.balance -= e.amount
        self.version = e.version

def load(store, stream, use_snapshot=True):
    acct = Account()
    if use_snapshot and stream in store.snapshots:
        v, (balance, is_open) = store.snapshots[stream]
        acct = Account(balance, is_open, v)
    for e in store.read(stream, after_version=acct.version):
        acct.apply(e)
    return acct

def withdraw(store, stream, amount):
    acct = load(store, stream)                       # rebuild current state
    if amount > acct.balance:                        # the command enforces the rule
        raise ValueError("insufficient funds")
    store.append(stream, acct.version, [("Withdrew", amount)])

def snapshot(store, stream):
    acct = load(store, stream)
    store.snapshots[stream] = (acct.version, (acct.balance, acct.is_open))

# A read model (the "Q" in CQRS): balances of all accounts, updated from events.
balances = {}
def project(e):
    if e.kind == "Opened":
        balances[e.stream] = 0
    elif e.kind == "Deposited":
        balances[e.stream] += e.amount
    elif e.kind == "Withdrew":
        balances[e.stream] -= e.amount

if __name__ == "__main__":
    store = EventStore(subscribers=[project])
    store.append("acct-1", 0, [("Opened", 0), ("Deposited", 100)])
    withdraw(store, "acct-1", 30)
    print("balance:", load(store, "acct-1").balance, "read model:", balances["acct-1"])

    # Two writers load the same version; the second append is rejected.
    a = load(store, "acct-1")
    store.append("acct-1", a.version, [("Withdrew", 50)])
    try:
        store.append("acct-1", a.version, [("Withdrew", 50)])
    except ConcurrencyError as err:
        print("rejected:", err)

    try:
        withdraw(store, "acct-1", 1_000)
    except ValueError as err:
        print("rejected:", err)

    # Replay cost with and without a snapshot.
    n = 1_000_000
    v = load(store, "acct-1").version
    store.append("acct-1", v, [("Deposited", 1)] * n)
    for use in (False, True):
        if use:
            snapshot(store, "acct-1")
            withdraw(store, "acct-1", 5)             # one event after the snapshot
        t = time.perf_counter()
        acct = load(store, "acct-1", use_snapshot=use)
        ms = (time.perf_counter() - t) * 1000
        print(f"snapshot={use!s:5} version={acct.version} balance={acct.balance} load={ms:.3f} ms")
    print("read model agrees:", balances["acct-1"] == load(store, "acct-1").balance)

Output on an Apple M3 Pro with Python 3.14:

balance: 70 read model: 70
rejected: acct-1: expected v3, at v4
rejected: insufficient funds
snapshot=False version=1000004 balance=1000020 load=123.738 ms
snapshot=True  version=1000005 balance=1000015 load=0.004 ms
read model agrees: True

A second run gave 104.8 ms without the snapshot and 0.003 ms with it. Replaying a million in-memory events took around a tenth of a second. Reading them from a database would add I/O on top. With a snapshot, loading applies only the events after it.

Snapshots are a cache, not a source of truth. They can be deleted and rebuilt from the log at any time, and they must be rebuilt when the code that applies events changes in a way that affects state. Common policies take a snapshot every N events, or when loading a stream took longer than a threshold.

Projections and read-your-writes

A projection turns events into a read model, such as a table of balances or a search index. Each projection records the last position it processed, so it can stop and resume. A new read model is built by replaying the whole log into it, which is one of event sourcing’s real strengths: new questions can be asked of old data.

Read models lag behind writes. A user who deposits money and immediately sees the old balance will think the deposit failed. Two common fixes:

  • The command returns the new version, and the client’s next read waits until the projection has reached at least that version.
  • The screen that follows a write reads from the write side, by loading the stream, rather than from the projection.

Changing events over time

Events are kept forever, but the code that reads them changes. Approaches, from simplest:

  • Only add optional fields. Old events lack them, and readers use defaults.
  • Upcasting. When an old event is read, a function converts it to the current shape before it’s applied. The stored event is untouched.
  • New event types. AddressChangedV2 alongside the old type, with both handled.
  • Rewriting the log, copying it to a new store with converted events. It’s possible, but it gives up the immutability that justified the design.

Deleting personal data conflicts with an immutable log. A common technique is crypto-shredding: encrypt each person’s fields with a per-person key, store keys separately, and delete the key when the data must go. The events stay, but the personal fields become unreadable. Chapter 19 covers the key management this depends on.

When to use them

CQRS without event sourcing is common and modest: write to normalized PostgreSQL tables, and serve some reads from a denormalized view, a read replica or a search index. Event sourcing without CQRS is rare, because querying a log directly is awkward.

Both together fit domains where the history is the product: ledgers, audit-heavy workflows, and systems that must answer “what did we know, and when?”. Fowler’s CQRS note warns that for most systems CQRS adds risky complexity, and suggests applying it to specific parts of a system rather than the whole. For a typical CRUD application, an updated_at column and an audit table give most of the history at a fraction of the cost.

The strangler fig

Replacing a large system in one step is risky. The replacement takes years, the old system keeps changing meanwhile, and much of the old behavior is undocumented. Fowler’s Strangler Fig Application names the alternative after a vine that grows around a host tree until it replaces it: build new parts alongside the old system, move behavior over piece by piece, and retire the old system when nothing is left in it.

Step 1                     Step 2                          Step 3
clients                    clients                         clients
   |                          |                               |
[ facade ]                 [ facade ]                      [ facade ]
   |                        /        \                         |
[ legacy ]          /orders -> new    rest -> legacy       [ new system ]

The steps:

  1. Put a facade in front. A reverse proxy or gateway that initially passes everything to the legacy system. Clients stop knowing which system serves them.
  2. Pick a slice. A capability with clear inputs and outputs, ideally one that changes often or causes pain. Read-only endpoints are a lower-risk start.
  3. Build it new, behind an anti-corruption layer. The new code talks to the legacy system through a translation layer, so the old data model doesn’t leak into the new one.
  4. Compare before switching. Send copies of real requests to the new code and compare its responses with the legacy system’s, discarding the new ones. Chapter 20 calls this shadow traffic.
  5. Switch gradually. Route a small share of traffic, then more, with the old path still available.
  6. Move the data. Usually the hardest part. A common sequence is: the legacy database stays the source of truth while change data capture (chapter 15) copies changes to the new store; then ownership flips, and changes flow the other way for anything still reading the old tables; then the old copy is removed. Writing to both databases from application code is the pattern to avoid, because the two writes can’t be made atomic.
  7. Delete the old code. A migration that stops at 90% leaves two systems to run indefinitely.

Inside a single codebase, the same idea is often called branch by abstraction: put an interface in front of the old implementation, build a new implementation behind the same interface, switch callers over with a flag, then delete the old one.

Choosing

StyleFits whenMain costsWarning signs
Modular monolithOne team or a few; domain still being learnedDiscipline to keep boundariesModules reaching into each other’s tables
MicroservicesMany teams needing independent deploymentNetwork failures, distributed data, operationsServices that must deploy together
SOA with a busIntegrating many existing enterprise applicationsCentral bottleneck, shared modelEvery change needs the bus team
Event-drivenMany independent reactions to the same factsInvisible flows, eventual consistency, schema contractsNobody can say where an order is
ServerlessSpiky or rare work, event handlers, glueLimits, cold starts, cost at steady high loadFunctions chained synchronously, connection exhaustion
CQRS and event sourcingHistory is the product; reads and writes differ sharplyReplay, versioning, deletion, lagUsed for plain CRUD

Questions that settle most choices:

  • How many teams will change this code at once? Below a handful, the coordination that microservices remove isn’t the bottleneck.
  • Where does consistency matter? Anything that needs an immediate, correct answer across steps is easier in one database.
  • What’s the traffic shape? Spiky and low suits functions; steady and high suits servers.
  • Who will be on call, and with what tools? Every style beyond the monolith needs tracing, consumer-lag metrics and good logs from the first day.
  • How will this change later? Prefer the style that keeps the next move cheap. A modular monolith with asynchronous internal flows can become services. A distributed monolith is much harder to undo.

The takeaway

Start with a modular monolith with enforced boundaries, and split along those boundaries when team scale demands it. Use events for facts and commands for requests, put correlation and trace IDs in every event, and measure consumer lag. Serverless is cheapest for spiky work, and a calculation with real durations and prices decides it for steady load. Event sourcing earns its cost where history matters, and snapshots keep replay cheap. Replace old systems gradually behind a facade, and finish by deleting the old code.