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:

  1. Layers inside one application — N-tier: how one program is organised.
  2. How many deployable programs — monolith, modular monolith, microservices: when and why to split.
  3. How the programs talk — a message broker or an enterprise service bus: where the integration logic lives once they are split.
  4. 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:

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:

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

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

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."