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:
| Question | Examples 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) | |
|---|---|---|
| Scope | The whole enterprise, integrating many applications | One application or product, split into services |
| Communication | Through a central bus that routes and transforms | Direct HTTP or gRPC, or a plain message broker |
| Where logic sits | Much of it in the bus: routing, mapping, orchestration | In the services. The pipes only carry messages |
| Data | Services often share databases | Each service owns its data |
| Data model | One canonical model for everyone | Each service has its own model, translated at the edges |
| Governance | Central architecture group, shared standards | Each team decides within light platform rules |
| Main goal | Reuse of shared services | Independent 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 notification | Event-carried state transfer | Event sourcing | |
|---|---|---|---|
| Event carries | An ID and a type | The changed data | Every change, as the source of truth |
| Consumer calls back? | Usually yes | No | Not applicable: the log is the data |
| Load on the source | Grows with consumers | Independent of consumers | Writes only |
| Consumer availability | Depends on the source being up | Works when the source is down | Not applicable |
| Data freshness | Fresh at call-back time | Eventually consistent copy | The log is authoritative; views lag |
| Main cost | Hidden call-back traffic | Copies of data everywhere, schema contracts | Replay, 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)
| Choreography | Orchestration | |
|---|---|---|
| Where the flow is defined | Spread across the consumers | In one orchestrator |
| Adding a step | Add a consumer; others don’t change | Change the orchestrator |
| Changing the order of steps | Change several services | Change one place |
| Seeing a stuck flow | Reconstruct it from logs and traces | Query the orchestrator’s state |
| Coupling | Consumers depend on event schemas | Orchestrator depends on every step’s API |
| Failure handling | Each service handles its own compensation | Central 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:
- Init. Lambda downloads the code, starts the runtime and runs initialization code outside the handler. This phase is limited to 10 seconds.
- Invoke. Lambda runs the handler for one request.
- 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:
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:
| Limit | Value |
|---|---|
| Function timeout | 900 seconds (15 minutes) |
| Memory | 128 MB to 10,240 MB, in 1 MB steps |
| CPU | Proportional to memory; 1,769 MB is the equivalent of one vCPU |
| Synchronous payload | 6 MB request and 6 MB response |
| Asynchronous payload | 1 MB |
/tmp storage | 512 MB to 10,240 MB |
| Deployment package | 50 MB zipped upload, 250 MB unzipped |
| Concurrent executions | 1,000 per Region by default, can be raised |
| Scaling rate | 1,000 new environments every 10 seconds, per function |
| Init phase | 10 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:
- Lambda, x86: $0.20 per million requests and $0.0000166667 per GB-second (Lambda pricing).
- EC2
m7i.large(2 vCPU, 8 GiB), Linux: $0.1008 an hour (EC2 on-demand pricing).
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 second | 512 MB, 50 ms | 1 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:
| Function | Against 1 server | Against 2 servers |
|---|---|---|
| 512 MB, 50 ms | 45.4 requests a second | 90.8 requests a second |
| 1 GB, 200 ms | 7.9 requests a second | 15.8 requests a second |
The numbers say less than they seem to, for several reasons:
- The server count is an assumption. Whether one
m7i.largecan 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 fit | Poor fit |
|---|---|
| Event handlers: file uploads, queue messages, webhooks | Steady high-throughput APIs |
| Spiky or rare traffic, scheduled jobs | Latency-critical paths where cold starts hurt |
| Glue between managed services | Long jobs beyond the timeout |
| Small teams without operations capacity | Workloads 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.
AddressChangedV2alongside 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:
- 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.
- 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.
- 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.
- 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.
- Switch gradually. Route a small share of traffic, then more, with the old path still available.
- 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.
- 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
| Style | Fits when | Main costs | Warning signs |
|---|---|---|---|
| Modular monolith | One team or a few; domain still being learned | Discipline to keep boundaries | Modules reaching into each other’s tables |
| Microservices | Many teams needing independent deployment | Network failures, distributed data, operations | Services that must deploy together |
| SOA with a bus | Integrating many existing enterprise applications | Central bottleneck, shared model | Every change needs the bus team |
| Event-driven | Many independent reactions to the same facts | Invisible flows, eventual consistency, schema contracts | Nobody can say where an order is |
| Serverless | Spiky or rare work, event handlers, glue | Limits, cold starts, cost at steady high load | Functions chained synchronously, connection exhaustion |
| CQRS and event sourcing | History is the product; reads and writes differ sharply | Replay, versioning, deletion, lag | Used 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.