Architectural Patterns and Anti-Patterns Questions
Architecture-level patterns and the anti-patterns that signal a wrong turn. Patterns: layered and n-tier architecture, including where cross-cutting concerns like authentication, rate-limiting and tracing belong, dependency injection trade-offs, and thin-versus-fat controller design; hexagonal (ports and adapters) and clean architecture; CQRS and event sourcing; backend-for-frontend; plugin (microkernel) extension models; and the coupling, cohesion, encapsulation and separation-of-concerns principles behind them, including when each applies and what it costs. Anti-patterns: distributed monolith, chatty services, shared-database coupling, cyclic service dependencies, leaky abstractions that expose internal schemas, and golden-hammer pattern adoption. Covers the detection signals (deploy coupling, call-graph fan-out, change amplification, trace evidence), incremental remediation, and architecture governance that keeps smells from recurring. This is about diagnosing and fixing the smell in an existing design, not the monolith-versus-microservices decision itself.
Describe a typical three-tier (multi-tier) layered web architecture: presentation/UI, application/business logic, and persistence/data. For each tier, name its responsibilities, then trace how a single request flows through the system, from the client through ingress, load balancing, and the application tier down to the data tier and back. What are the trade-offs of this architecture (scalability, deployment complexity, testability) compared to a simpler two-tier design or a flatter, event-driven one?
Sample Answer
Direct answer
A three-tier architecture splits a web application into three independently deployed parts: the presentation tier (what runs in or is sent to the user's browser or app), the application tier (the servers that apply business rules) and the data tier (the databases and stores that keep state). Each tier talks only to its neighbour, so the browser never touches the database directly. You pay for an extra network hop and more moving parts, and in exchange you get a stateless middle tier you can scale by adding servers, one place to enforce security and rules, and parts you can test and deploy separately.
A useful distinction up front: a tier is a physical deployment unit (separate machines or containers), while a layer is a logical grouping of code inside one program. Three-tier is about tiers.
The three tiers and what each owns
| Tier | Responsibilities | Typical technology | Should NOT do |
|---|---|---|---|
| Presentation | Render screens, capture input, client-side validation for fast feedback, call the API | React or server-rendered HTML, mobile app, static assets on a CDN (content delivery network: servers near users that cache files) | Hold business rules or database credentials |
| Application | Authenticate and authorize, validate input authoritatively, apply business rules, coordinate transactions, call other services, shape responses | Stateless API servers (Java, Go, Node, Python) behind a load balancer | Keep user session state in local memory (breaks horizontal scaling) |
| Data | Store and retrieve durable state, enforce integrity (keys, constraints), run indexed queries, replicate and back up | PostgreSQL or MySQL, plus a cache such as Redis and object storage for files | Encode business workflows in stored procedures that nobody can test |
Tracing one request: GET /orders/123
- Client. The browser already loaded the presentation code from the CDN. The user opens "My orders", and the JavaScript sends
GET /api/orders/123over HTTPS with a session token. - Ingress and load balancing. DNS resolves the API hostname to a load balancer (or, in Kubernetes (a container-orchestration system: software that runs and manages many server instances across a fleet of machines for you, restarting failed ones and scheduling new ones), an ingress controller: the component that admits outside traffic into the cluster). It terminates TLS (decrypts HTTPS), checks which application instances are passing health checks (a periodic ping each instance must answer, so the load balancer stops sending traffic to one that has stopped responding), and forwards the request to one of them.
- Application tier. The chosen instance validates the token, checks the rule "a customer may only read their own orders", and asks its data-access code for order 123. It may check a cache first.
- Data tier. A connection is borrowed from the instance's connection pool (a set of already-open database connections the instance keeps ready, so a request reuses one instead of paying the cost, tens of milliseconds, of opening a fresh connection to the database each time), an indexed
SELECTruns against the orders table, and rows come back. - Back up the chain. The application maps rows to a JSON response (dropping internal columns), returns 200, the load balancer relays it, and the browser renders the page.
sequenceDiagram
participant B as Browser
participant LB as Load balancer
participant App as App server
participant DB as Database
B->>LB: GET /api/orders/123 over HTTPS
LB->>App: forward to a healthy instance
App->>App: check token and ownership rule
App->>DB: SELECT order 123
DB-->>App: rows
App-->>LB: 200 JSON
LB-->>B: 200 JSON
Because step 3 keeps no per-user state in memory, any instance can serve the next request from the same user. That property is what lets the middle tier scale out.
Worked example: where the bottleneck moves
Suppose peak load is 1,200 requests per second (RPS) and a load test shows one application instance sustains about 150 RPS.
- Instances needed: 1,200 / 150 = 8. Run 10 so that losing one or two still leaves capacity (25% headroom).
- Each instance keeps a database connection pool of 20 connections: 10 x 20 = 200 connections.
- PostgreSQL's
max_connectionsdefault is typically 100 (the documentation notes it can be lower if kernel settings do not support 100).
So the design that scales the application tier by adding boxes will exhaust the data tier at peak, exactly when the autoscaler (the system that automatically adds or removes application instances based on load) adds instances. Fixes: shrink each pool (10 x 8 = 80 connections), or put a connection pooler such as PgBouncer between the tiers: a lightweight service that holds a smaller, fixed number of real connections to Postgres and multiplexes many application-side requests through them. PgBouncer's own default is session pooling, which hands one real connection to an application connection for its whole session and would not shrink the connection count here; the setting this design actually needs is transaction pooling mode, which hands a connection out per transaction and takes it back the instant that transaction commits or rolls back, then reuses it for the next request. PgBouncer also offers a statement pooling mode that returns the connection after every individual query, but that mode explicitly disallows transactions spanning multiple statements, which would break a multi-step operation like a funds transfer, so transaction mode, not the default and not statement mode, is the setting to reach for. That lets the same 200 application-side connections be served through, say, 50 real database connections instead of colliding with the 100-connection ceiling. The general lesson is that the application tier scales horizontally cheaply and the data tier does not, so in a three-tier system the bottleneck usually migrates to the database.
Trade-offs versus two-tier and event-driven designs
A two-tier design has the client talk straight to the database (a desktop app with a database driver, or a small server-rendered app where UI code and SQL live in one process). An event-driven design has components publish events ("OrderPlaced") to a broker such as Kafka (a system dedicated to durably queueing and delivering these events to every interested component, so the publisher does not need to know who is listening or whether they are online right now), and other components react asynchronously instead of being called directly.
| Dimension | Two-tier | Three-tier | Event-driven |
|---|---|---|---|
| Scalability | Every client holds database connections; the database takes all load | Stateless middle tier scales out; the database remains the limit | Consumers scale independently; the broker absorbs spikes |
| Deployment complexity | Lowest: one app plus a database, but rolling out a desktop client means updating every install | Moderate: load balancer, app fleet, database, each with its own deploy | Highest: broker, topics, consumers, schema versioning for events |
| Testability | Rules mixed with UI or SQL are hard to test in isolation | Business rules testable in the application tier without a UI | Each consumer testable alone, but end-to-end flows are hard to test and debug |
| Security | Database credentials sit on or near the client | Database reachable only from the app tier | Must also secure the broker and every topic |
| Time to market | Fastest for small internal tools | Reasonable default for most web products | Slowest to start; pays off when work can happen later |
| Failure behaviour | Database down means everything is down | A slow database slows every request synchronously | A slow consumer builds a backlog but the user-facing request still succeeds; data is eventually consistent (other parts catch up after a delay) |
Recommendation. Use three-tier as the default for a customer-facing web product. Use two-tier only for small internal tools with a handful of trusted users. Add event-driven pieces for work the user does not need to wait for (emails, search indexing, analytics), which gives a hybrid: synchronous three-tier for the request path, events for side effects. What would flip this: if most work is naturally asynchronous (ingesting sensor data, processing uploads), an event-driven core is the better starting point.
Pitfalls that separate strong answers
- Stateful application servers. Sessions kept in instance memory force "sticky" routing (the load balancer must keep sending one user to the same instance every time, because that instance is the only one holding their session in memory), and users get logged out when an instance dies. Keep sessions in a token or a shared store.
- A pass-through middle tier. If the application tier only forwards requests to SQL, you paid for a hop and got nothing; the rules have leaked into the client or into stored procedures (business logic written and run inside the database itself, rather than in the application tier).
- Confusing tiers with layers. Splitting every code layer onto its own network hop adds latency without adding isolation you need.
- Forgetting that tiers fail together. In a synchronous chain, a slow data tier makes the whole request slow; the architecture does not isolate that by itself.
You are designing a new SaaS web application with a frontend, API gateway, application layer, business logic layer, and persistence layer. For each layer, describe its core responsibilities, what interfaces it should expose, and how you'd enforce separation of concerns to ease testing, deployment, and security boundaries across teams.
Sample Answer
Direct answer
I would treat the five layers as logical boundaries with a strict dependency direction, not as five separately deployed services. As a request flows through the system, each layer calls only the layer directly beneath it (frontend to gateway, gateway to application) or an interface owned by an inner layer (application to persistence, through a repository interface the domain layer defines), never sideways, and never back up to a layer closer to the user. All of those calls still obey one further rule about dependencies, which is a separate thing from calls: dependencies point inward toward the business rules, meaning the domain layer is the one thing every other layer's code is allowed to depend on (import, call, or implement an interface from), and the domain layer depends on nothing outside itself. The next section spells out what "inward" means and why that lets application call persistence directly without breaking the rule. The business logic layer knows nothing about HTTP or SQL. That gives each concern one home: the gateway handles cross-cutting edge concerns (things every request needs regardless of which use case it is, such as authentication and TLS, handled once at the boundary instead of being repeated inside every use case), the application layer orchestrates use cases, the domain layer holds the rules, and persistence hides the database. I enforce the boundaries mechanically (build-time architecture tests, separate modules, explicit data-transfer objects) rather than by convention, because conventions erode under deadline pressure.
flowchart TB
FE[Frontend: SPA on CDN] -->|HTTPS + JSON contract| GW[API gateway]
GW -->|authenticated request + tenant/user context| APP[Application layer: use cases]
APP -->|calls domain objects/services| DOM[Business logic: domain model]
APP -->|repository interfaces| PER[Persistence adapters]
PER --> DB[(Postgres)]
PER -.implements interfaces owned by.-> DOM
Two labels in that diagram are worth spelling out before going further. SPA means single-page application: a browser app that loads once and then updates itself by calling the API, rather than requesting a fresh HTML page for every click. Persistence adapters are the concrete, database-specific code (here, Postgres-specific) that implement an interface the domain layer defines; "adapter" is the standard name for that kind of outer-layer implementation, and the next paragraph explains the pattern it belongs to.
The dotted line is the important one: the domain layer defines the repository interface ("load this invoice", "save this invoice"), and persistence implements it. That is dependency inversion, and it is what lets the business rules be tested with no database.
This pattern has a name worth knowing: ports and adapters (also called hexagonal architecture). A port is an interface the domain defines for something it needs from the outside world (here, "a place to load and save invoices"); an adapter is the concrete, outer-layer code that plugs into that port (here, the Postgres-specific code in persistence). Picture the domain sitting at the centre, with every other layer forming a ring around it: "inward" means every layer's code is allowed to depend on the layer closer to the centre, never the reverse. That reconciles the two rules above: the runtime call from application to persistence looks like it reaches past the domain layer, but the interface it calls through is one the domain owns, so the dependency created by that call still points at the domain, not around it. Persistence, for its part, imports and implements that domain-owned interface, so its dependency also points inward, toward the centre, even though the request itself flows outward from application to persistence to the database.
Layer by layer
| Layer | Core responsibilities | Interface it exposes | Must NOT do |
|---|---|---|---|
| Frontend | Rendering, client-side validation for UX, navigation, local UI state | Nothing to other layers; consumes the public API contract | Enforce security or business rules (it runs on the user's machine and can be bypassed) |
| API gateway | TLS termination (decrypting incoming HTTPS at the gateway, so the connection arrives encrypted from the internet but the layers behind the gateway can talk plain HTTP internally), authentication (validating the token), rate limiting, request size limits, routing, request IDs for tracing | The public HTTP API (documented with OpenAPI, a machine-readable API description format) | Business decisions, data shaping per use case, database access |
| Application layer | One handler per use case ("CreateInvoice"): authorization in context (may this user act on this tenant's invoice?), transaction boundaries, orchestration of domain objects and repositories, mapping between API DTOs (data-transfer objects, plain request/response shapes) and domain objects | Use-case functions or command/query handlers that take DTOs and return DTOs | Contain business rules (it coordinates them), know SQL |
| Business logic (domain) | Entities and rules: an invoice cannot be issued with zero line items, tax is computed per jurisdiction, a paid invoice cannot be edited | Domain objects and domain services; repository and external-service interfaces (ports) it needs | Import any web framework, ORM (object-relational mapping library), or HTTP client |
| Persistence | Mapping domain objects to tables, queries, migrations, connection handling, tenant scoping as defence in depth (a second, independent enforcement of tenant isolation here, in case the application layer above ever forgets its own check; it backs up that check, it does not replace it) | Implementations of the repository interfaces | Leak ORM entities or table shapes upward; decide business outcomes |
Worked example: "issue invoice"
- The frontend sends
POST /invoices/881/issue. - The gateway validates the JSON Web Token (JWT, the signed login token), applies the tenant's rate limit, attaches a request ID, and forwards the call with
user_idandtenant_idas trusted context. - The application layer's
IssueInvoicehandler checks that this user has the billing role on this tenant, opens a transaction, and loads invoice 881 throughInvoiceRepository. - The domain object's
issue()method enforces the rules (not already issued, at least one line, totals recomputed) and records an "issued" state. - The handler saves through the repository, commits, and maps the result to an
InvoiceResponseDTO. - Persistence writes the rows, with the query scoped by
tenant_id, and Postgres row-level security (RLS: a database policy that hides rows belonging to other tenants) as a second guard.
If the tax rule changes, one domain class changes. If the database moves from Postgres to another store, only persistence adapters change. If the mobile app needs a new response shape, only the API DTO mapping changes.
How I enforce separation of concerns
For code boundaries
- One module (package, project or workspace) per layer, with the build graph forbidding inward-to-outward imports.
- An architecture test in CI that fails the build on a forbidden dependency: ArchUnit's
layeredArchitecture()rules in Java, import-linter "layers" contracts in Python, dependency-cruiser rules in TypeScript. This is the single most effective mechanism because it turns a design principle into a red build. - API DTOs are separate types from domain objects and from ORM entities, so a column rename cannot silently change the public response.
For testing
- Domain: fast unit tests with no mocks and no I/O.
- Application: tests using in-memory fakes of the repository interfaces.
- Persistence: integration tests against a real Postgres in a container, because a fake cannot catch a wrong SQL query.
- Frontend-to-API: contract tests against the OpenAPI document, so either side learns immediately when the other breaks the contract.
For deployment
- Frontend ships to the CDN (content delivery network) on its own schedule.
- Gateway configuration (routes, rate limits) is versioned and deployed separately.
- Application, domain and persistence ship as one deployable. Splitting them into separate network services would turn each in-process call into a network hop, which is how layered designs become chatty distributed monoliths.
For security boundaries
- Authentication at the gateway, authorization in the application layer (where the use case and the resource are both known), tenant isolation enforced again in the database.
- Only the persistence layer holds database credentials, with a least-privilege role (no DDL rights at runtime: DDL, or data-definition-language statements such as CREATE TABLE or ALTER TABLE, change the schema itself, as opposed to the DML statements that read and write rows; migrations run under a separate role that does hold DDL rights).
For teams
- Code ownership files map layers to owning teams, so a change to domain rules requires a domain owner's review, and platform engineers own the gateway configuration.
Trade-offs and pitfalls
- Anemic domain: all rules end up in application handlers and the "domain" is getters and setters. Watch for handlers that contain
ifstatements about business policy. - Layer skipping: a controller (an application-layer handler, the thing that receives the incoming call in step 3 of the worked example above) calling the repository directly for "just a quick read". Allow it only for explicitly read-only query paths, and make that an explicit rule rather than an accident.
- God gateway: teams start adding per-endpoint transformations and business checks in gateway plugins because it is the easiest place to deploy. The gateway should stay boring.
- Ceremony cost: three mapping layers for a simple CRUD (create, read, update, delete) screen is overhead. For a small internal admin screen it is fine to let the application layer map directly from the repository to a DTO, as long as the ORM entity never leaves the persistence module.
That is every published Architectural Patterns and Anti-Patterns question for Systems Engineer so far. Browse the other topics in this category, or practice this one interactively.