R1.5 — Architecture styles: how the pieces are arranged
Part of R1. R1.1 to R1.4 gave you the parts: connections and request handling (R1.1), services that keep state or don't (R1.2), gateways (R1.3), and APIs and ways of talking back to the client (R1.4). An architecture style is the decision about how those parts are arranged and connected. Interviewers ask about it directly ("monolith or microservices?") and indirectly, in every design, when they ask why a box is separate from another box.
Four ideas cover almost every question, and each grows out of the one before:
- Layers inside one application — N-tier: how one program is organised.
- How many deployable programs — monolith, modular monolith, microservices: when and why to split.
- How the programs talk — a message broker or an enterprise service bus: where the integration logic lives once they are split.
- What the programs store — event sourcing: keeping every change as a fact, which is where messaging and storage meet.
Layers inside one application: N-tier
An application is easier to change when each part has one job. The classic arrangement is layers, also called tiers:
| Layer | Job | In a Spring Boot payment service |
|---|---|---|
| Presentation | Accept requests, return responses; no business rules | @RestController, request/response DTOs, validation annotations |
| Business (domain, service) | The rules: can this payment be made, what fee applies | @Service classes, domain objects such as Payment, Money |
| Data (persistence) | Read and write storage | @Repository, JPA entities or JdbcTemplate |
Each layer talks only to the one below it. The controller never touches the database; the repository never decides a fee. Replace the database, and only the data layer changes; add a mobile API, and only the presentation layer grows.
"Tier" sometimes means a physical split rather than a logical one:
- 1-tier: everything on one machine — a desktop app with an embedded database.
- 2-tier: client and database — a thick client talking straight to SQL. Common in older branch-banking software; hard to secure and to change.
- 3-tier: browser, application server, database — the standard web arrangement.
- N-tier: more physical layers — a CDN, a gateway, several service tiers, caches, databases.
Layering is not an alternative to microservices — every microservice is itself a small layered application. The questions below are about how many of those applications you run, not whether to layer them.
What's being probed: whether you separate responsibilities inside a service before talking about splitting services.
Concept — logical layers versus physical tiers. Layers are boundaries in the code; tiers are boundaries in deployment. A 3-tier web app has three physical tiers and, inside the application tier, still three logical layers. Keeping business rules out of controllers and repositories is what makes any later split possible, because a well-layered module can be lifted out whole.
Takeaway: Layer first, inside every service; tiers are a deployment choice on top.
Monolith, modular monolith, microservices: a deliberate trade-off
A monolith is one deployable program containing every feature: payments, accounts, notifications, reporting, all in one codebase, built and released together, usually sharing one database.
Microservices split those features into separately deployed services, each owning its own data and talking over the network.
Between them sits the option people forget: the modular monolith — one deployable program, but divided internally into modules with hard boundaries. Each module owns its tables, exposes a small public interface, and other modules may not reach into its internals. In Java, packages with enforced rules (ArchUnit tests, Java modules, or a framework such as Spring Modulith) keep the boundaries honest.
| Monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deployables | One | One | Many |
| Calls between features | In-process method calls | In-process, only through module interfaces | Network calls (HTTP/gRPC) or messages |
| Data | One shared schema; any code reads any table | One database, but each module owns its tables | A database per service |
| Transactions across features | Easy: one local transaction | Easy: one local transaction | Hard: sagas, outbox, idempotency (R4, R5, R3) |
| Scaling | Scale the whole thing | Scale the whole thing | Scale each service separately |
| Team independence | Low: everyone shares one release | Medium: clear ownership, shared release | High: each team releases on its own |
| Failure isolation | One bug can crash everything | Same | A failing service can be contained (R14) |
| Operational cost | Lowest | Low | High: deployment pipelines, tracing, service discovery, on-call per service |
The trade-off in one sentence: microservices buy independent deployment, scaling and failure isolation, and pay for it with network calls, distributed data and operational complexity. Every cross-service flow that used to be a local transaction becomes a saga with idempotency keys.
When to split, and when not to:
- Start with a monolith or, better, a modular monolith when the team is small, the domain is still being discovered, or traffic is modest. You cannot draw good service boundaries before you understand the domain, and moving a boundary inside one codebase is a refactor, while moving it between services is a migration.
- Split a module out when it has a real reason to be separate: a different team that needs its own release cadence, a very different scaling profile (a CPU-heavy risk engine next to a light CRUD API), a different availability requirement (payments must stay up while reporting can fail), or a regulatory boundary (card data kept in its own PCI-scoped service).
- Split along business capabilities, not technical layers. "Payments", "Accounts", "Notifications" are good services; "the database service" or "the validation service" are not, because every change crosses them.
The distributed monolith is the worst of both worlds
Services that must be deployed together, share a database, or call each other synchronously in long chains are a monolith with network latency added. Signs: a change needs releases of four services in a set order; one service's outage takes down all the others; services read each other's tables. Conway's law — systems mirror the communication structure of the teams that build them — explains why it happens: boundaries drawn without matching team ownership leak.
The migration path is gradual, never a big bang: carve out one module at a time behind the strangler fig pattern, with a shadow period and expand-contract schema changes (R16). A modular monolith makes each carve-out cheap, because the module already owns its tables and exposes an interface.
Step through the evolution below: one monolith, the same code as a modular monolith, one module carved out as a service, and what a distributed monolith looks like when the boundaries are wrong.
What's being probed: whether you treat microservices as a trade-off with a cost, not a default.
Concept — modularity first, distribution second. Most of the benefits people want from microservices — clear ownership, small blast radius for code changes, independent reasoning — come from modularity. Only some — independent deployment, separate scaling, failure isolation — need distribution. A modular monolith gets the first set without paying for the second. Split only when a specific module needs something only distribution gives.
Takeaway: Draw module boundaries early; turn a module into a service only when it needs independent deployment, scaling or failure isolation.
Connecting services: message broker or enterprise service bus
Once a system is split, the services must exchange information. R9 covers the mechanics of queues, topics and Kafka. The architectural question is where the integration logic lives.
An enterprise service bus (ESB) is a central piece of middleware that every system plugs into. It does a lot: routes messages, transforms formats (an MT103 into an internal JSON, a SOAP call into a REST call), orchestrates multi-step processes, applies security and retries. It was the backbone of service-oriented architecture (SOA) in the 2000s, and many banks still run one, often alongside IBM MQ or a similar product.
A message broker — RabbitMQ, ActiveMQ, Kafka, SQS — deliberately does much less: it stores and delivers messages. Routing is simple (a queue, a topic, a key), and the transformations and business decisions live in the services at each end. The phrase for it is smart endpoints, dumb pipes.
| Enterprise service bus | Message broker | |
|---|---|---|
| Where business logic sits | Partly in the bus: routing rules, transformations, orchestration | Entirely in the services |
| Who changes it | A central integration team | Each service's own team |
| Coupling | Every integration depends on the bus and its team | Services depend on a message contract only |
| Failure blast radius | The bus is a critical shared dependency | Brokers are simpler, clustered, and replaceable |
| Strengths | Connecting many legacy systems with different protocols and formats; central governance | Scaling, team independence, event streaming and replay (Kafka) |
| Typical products | IBM Integration Bus/App Connect, MuleSoft, TIBCO, Oracle Service Bus | RabbitMQ, ActiveMQ, Kafka, Amazon SQS/SNS |
The modern view: keep the pipe dumb, keep logic in services that teams own, and make the messages the contract. Where transformation is genuinely needed — at the edge of the bank, translating partner formats — put it in a dedicated adapter service owned like any other service, rather than in a shared bus. R25 shows the same idea at the payment rails, where ISO 20022 and legacy formats are translated at the boundary.
What's being probed: whether you know why ESBs fell out of favour, and can still respect them in a bank.
Concept — smart endpoints, dumb pipes. A central bus that holds routing and transformation rules becomes a bottleneck for change: every new integration waits for one team, and a bug in the bus breaks many flows at once. Brokers keep the infrastructure simple and push decisions to the owners of the data. In a bank you will meet both; the answer is not "rip out the ESB" but "stop adding business logic to it, and put new integrations behind owned adapters and events".
Takeaway: Put integration logic in services you own, and keep the pipes boring.
Event sourcing: store what happened, not just where you ended up
Most systems store current state: an accounts table says the balance is ₹5,000. When it changes, the old value is overwritten. If you want to know how it got there, you need a separate audit log — and hope it agrees with the table.
Event sourcing turns this around. The system stores every change as an immutable event — AccountOpened, MoneyDeposited ₹10,000, MoneyWithdrawn ₹5,000 — in an append-only event store. The current state is not stored as the truth at all: it is computed by replaying the events in order.
AccountOpened(id=A1) balance = 0
MoneyDeposited(A1, 1000000) -- paise balance = 10,000
MoneyWithdrawn(A1, 500000) balance = 5,000 ← current state = replay of the log
It is a ledger, and that is why it suits finance: an accountant never erases an entry; a mistake is corrected by a new, compensating entry.
How it works
An event stream holds the events for one aggregate — one account, one payment, one order. Each event has a version number in its stream. Appending is guarded by optimistic concurrency: a writer says "append this as version 4", and the store refuses if version 4 already exists.
CREATE TABLE event_store (
stream_id TEXT NOT NULL, -- e.g. 'account-A1'
version BIGINT NOT NULL, -- 1, 2, 3, ... per stream
event_type TEXT NOT NULL, -- 'MoneyWithdrawn'
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (stream_id, version) -- two writers cannot both append version 4
);
To handle a command, the service loads the stream, rebuilds the aggregate, checks the rule, and appends the new event at the next version:
public final class Account {
private long balance; // paise
private long version; // last applied event
public static Account replay(List<Event> history) {
Account a = new Account();
history.forEach(a::apply);
return a;
}
// command: decide, but do not change state yet
public MoneyWithdrawn withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
if (balance < amount) throw new InsufficientFunds(balance, amount);
return new MoneyWithdrawn(amount);
}
// event: the only place state changes — must never fail, and must be deterministic
void apply(Event e) {
switch (e) {
case AccountOpened o -> balance = 0;
case MoneyDeposited d -> balance += d.amount();
case MoneyWithdrawn w -> balance -= w.amount();
}
version++;
}
}
// application service: load, decide, append with the expected version
var history = store.load("account-A1");
var account = Account.replay(history);
var event = account.withdraw(500_000);
store.append("account-A1", account.version() + 1, event); // conflict → reload and retry
Snapshots stop replay getting slow: every few hundred events, store the computed state with its version, and replay only the events after it. A snapshot is a cache — delete it and you lose nothing.
Projections are read models built from the events: an account_balances table for fast lookups, a statement view, a search index. Each projection is a consumer that reads events in order and updates its table, remembering the last version it applied so it can be replayed safely. This is CQRS (R11): commands go to the event-sourced write side, queries go to projections. If a projection has a bug, fix the code, wipe the table and replay every event to rebuild it.
Step through it below: appending events, rebuilding state, two writers racing for the same version, a projection feeding a read model, a snapshot, and rebuilding a broken projection by replay.
What it gives you, and what it costs
| Gives you | Costs you |
|---|---|
| A complete, trustworthy audit trail — the log is the data | More concepts: streams, aggregates, projections, snapshots |
| Time travel: the state as of any moment, by replaying up to it | Reads are eventually consistent: projections lag the log |
| New read models from old data, by replaying into a new projection | Events are forever, so their schemas must evolve carefully (versioned events, "upcasters" that translate old shapes) |
| Natural fit for ledgers, payments, orders and workflows | Querying across aggregates needs projections; you cannot just SELECT the current state |
| Debugging by replaying the exact sequence that caused a bug | Deleting personal data is hard in an immutable log — usually solved by encrypting personal fields with a per-user key and deleting the key (crypto-shredding) |
Three things it is often confused with
- Event-driven architecture means services communicate by publishing events (R9). A service can be event-driven while storing plain current state. Event sourcing is a storage choice; event-driven is a communication choice. They combine well but are independent.
- The outbox (R4) stores an outgoing event in the same transaction as a state change, so the event is never lost. In an event-sourced service the event store is the outbox: appending the event is the state change, and publishers read from the store.
- CQRS (R11) separates the write model from read models. Event sourcing almost always needs CQRS; CQRS does not need event sourcing — a read model can be fed from ordinary change events.
Use event sourcing where the history is the business — ledgers, payments, orders — not as a default for every service. A user-profile service gains nothing from replaying "ChangedEmail" events, and pays all the costs.
Rules of thumb for event sourcing
- Name events in the past tense, as business facts:
PaymentAuthorised, notUpdatePaymentStatus. - Keep
applydeterministic and free of side effects; send emails and call partners from projections or process managers, not while rebuilding state. - Guard every append with the expected version, and treat a conflict as "reload, re-decide, retry".
- Make projections idempotent by tracking the last applied version per stream.
- Version event schemas from day one, and never edit or delete a stored event — correct with a compensating event.
- Snapshot only when replay time is measured to be a problem.
What's being probed: whether you know what event sourcing really costs, and can place it precisely next to CQRS, the outbox and event-driven design.
Concept — state as a fold over history. Current state is the result of applying every event, in order, to an empty starting point — a fold, in functional terms. Because the events are the truth, anything derived from them (state, read models, snapshots) can be thrown away and rebuilt. The price is that the events become a permanent public contract with your future self, so their design and versioning matter more than any table schema.
Takeaway: Store facts, derive state; keep events immutable and versioned; pay the cost only where history matters.
What to say in the interview
"I start with a modular monolith and carve out a service only when it needs independent deployment, scaling or failure isolation. Inside each application I keep clean layers; between services I keep integration logic in services we own rather than in a shared bus; and I use event sourcing where the history is the business — the ledger — not everywhere."