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.
Explain modularity as an architecture principle and how it differs from simple componentization. Give a concrete example of module boundaries for a ride-sharing service (matching, billing, routing), and describe how you'd enforce those module boundaries at the code, CI, and runtime levels.
Sample Answer
Direct answer
Modularity means dividing a system into parts that each own one cohesive responsibility, expose a small published interface, and hide everything else, so a part can change internally without anyone else noticing. Componentization is just dividing a system into parts (libraries, packages, services) that can be built or deployed separately. You can componentize without being modular: ten separate packages that all reach into each other's internals are components, not modules. The difference is whether the boundaries are respected, and that is why modularity has to be enforced at several levels (code, CI and runtime) rather than assumed.
Modularity vs componentization
| Componentization | Modularity | |
|---|---|---|
| Question it answers | "What are the pieces?" | "What may each piece know about the others?" |
| Unit | A package, jar, library or deployable | A responsibility with an interface and hidden internals |
| Can be achieved by | Moving code into separate folders or builds | Designing interfaces and enforcing that others use only them |
| Failure mode | Pieces exist, but depend on each other's internals | Rarely accidental; it degrades when boundaries are not enforced |
The encapsulation and modularity lens
Two related ideas pull in the same direction but pay off differently:
- Encapsulation (hiding a module's internal data and logic behind its interface) mainly improves maintainability: Billing can rewrite its fare-calculation internals and nobody else changes.
- Modularity (splitting the system along cohesive boundaries) mainly aids scalability, in two senses: of teams (the matching team works without coordinating with billing) and of load (a well-bounded module can later be extracted and scaled on its own, because nothing reaches into its internals).
Encapsulation is what makes modularity real; without it, module boundaries are only folder names.
Worked example: a ride-sharing service
Three modules inside one codebase (a modular monolith), each with a public interface and private internals:
| Module | Owns | Public interface (examples) | Private (others may not touch) |
|---|---|---|---|
| Matching | Pairing riders with nearby drivers | requestMatch(riderId, pickup), emits TripMatched | Driver-location index, ranking heuristics |
| Routing | Paths and travel-time estimates | estimateEta(from, to), route(from, to) | Road-graph data, map-provider client |
| Billing | Fares, payment, receipts | quoteFare(tripSpec), handles TripCompleted | Pricing tables, payment-provider client, invoice records |
Allowed dependencies: Matching calls Routing.estimateEta to rank drivers by arrival time. Billing calls Routing.route for distance when quoting a fare. Billing listens to TripCompleted. Not allowed: Billing reading Matching's driver-location tables, or Matching computing fares itself.
Why these boundaries: each changes for a different reason (matching heuristics, map data, pricing policy) and is owned by a different team. A surge-pricing change touches only Billing.
Enforcing the boundaries at three levels
1. Code level: make the wrong import impossible to write
- Use the language's visibility rules. In Go, anything under a directory named
internal/can be imported only by code within the parent of that directory, and the compiler enforces it. In Java, the module system (amodule-info.javafile that declares which of a module's own packages it exports for others to use) exports only chosen packages; everything else is inaccessible to other modules. - Each module exposes one public package (its API); implementation classes stay package-private (a Java visibility level between public and private: visible to other classes in the same package, but invisible outside it, so nothing outside the module can import them even by accident).
2. CI level: fail the build on a boundary violation
- Add architecture tests that encode the allowed dependency rules: ArchUnit for Java, dependency-cruiser for JavaScript/TypeScript, import-linter for Python. Example rule: "classes in
billing..(the..is ArchUnit's wildcard for 'this package and every package underneath it') may depend onrouting.api..but not onmatching..". - Run them on every pull request, next to unit tests. A violation fails the build with the rule's name, so it is caught at review time, not months later.
- Each module owns its own tests and database migrations folder, so ownership is visible in the repository structure.
3. Runtime level: make shared state impossible to reach
- Separate data ownership: each module gets its own database schema and its own database user, and Billing's user has no grants (database permissions explicitly given to that user) on Matching's schema. A sneaky query fails with a permission error rather than silently working.
- If modules are later deployed as separate services: network policies (firewall-like rules, enforced by the network or the platform, stating exactly which service may open a connection to which other service) allow only the declared calls (Matching may reach Routing; Billing may not reach Matching), and service identity checks (the receiving service cryptographically verifies which service is calling, for example from a mutual-TLS certificate, instead of trusting whatever network address the request came from) reject unexpected callers. Concretely: if a bug in Billing tried to call Matching's driver-location endpoint directly, the network policy would refuse the connection before it reached Matching at all, since Billing's policy only permits outbound calls to Routing; and even if that were somehow bypassed, Matching's identity check would see a certificate identifying the caller as "billing-service", find it is not on Matching's declared list of allowed callers, and reject the call.
- Monitor the runtime dependency graph (from tracing) and alert on any edge that is not in the declared list.
Pitfalls
- Folders are not modules. Splitting into packages without enforcement gives you componentization that decays back into a tangle.
- A shared "common" module that accumulates domain types (Trip, Fare, Driver) becomes the thing every module depends on, and every change to it touches everyone.
- Premature distribution. Good modules inside one deployable give most of the maintainability benefit without network cost. Extract a module into a service only when it needs to scale or deploy on its own.
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.
Explain dependency injection (DI) and the differences between constructor injection, setter injection, and the service-locator approach. When designing a layered backend, what are the advantages and the potential pitfalls of leaning on a DI framework in your service and repository layers?
Sample Answer
Direct answer
Dependency injection (DI) means an object receives the collaborators it needs (a repository, a clock, an HTTP client) from outside, instead of creating or looking them up itself. Constructor injection passes required dependencies as constructor arguments, so the object is complete the moment it exists. Setter injection assigns dependencies after construction through setter methods, which suits optional ones. A service locator is the opposite direction: the object asks a global registry for what it needs at run time, which hides its dependencies. Use constructor injection by default, setters only for genuinely optional dependencies, and keep service locators out of business code.
The three approaches compared
| Constructor injection | Setter injection | Service locator | |
|---|---|---|---|
| How dependencies arrive | Constructor parameters | Setter methods after construction | Object calls Registry.get("x") itself |
| Are dependencies visible? | Yes, in the signature | Partly (must read the setters) | No, buried in method bodies |
| Can the object exist half-built? | No | Yes, until every setter runs | Looks complete, fails later |
| Immutability | Fields can be final or read-only | Fields must stay mutable | N/A |
| When a dependency is missing | Fails at construction (or at application startup with a container) | Null reference at first use | Lookup error at first call, possibly in production |
| Good for | Required collaborators | Optional collaborators with a sensible default | Framework glue at the edge of the application |
Some frameworks also allow field injection (the container writes directly into a private field). It hides dependencies like a locator does and makes plain unit testing awkward; Spring's own reference documentation recommends constructor injection for mandatory dependencies and setters for optional ones.
Worked example: why hidden dependencies hurt
This runnable Python program builds the same service two ways.
class Registry: # a service locator: a global lookup table
_services = {}
@classmethod
def register(cls, name, obj):
cls._services[name] = obj
@classmethod
def get(cls, name):
return cls._services[name]
class OrderServiceWithLocator:
def total(self, order_id):
repo = Registry.get("order_repo") # hidden dependency
return sum(repo.line_amounts(order_id))
class OrderServiceWithConstructor:
def __init__(self, repo): # dependency is visible in the signature
self.repo = repo
def total(self, order_id):
return sum(self.repo.line_amounts(order_id))
class FakeOrderRepo:
def line_amounts(self, order_id):
return [1200, 350]
print("constructor:", OrderServiceWithConstructor(FakeOrderRepo()).total("o1"))
svc = OrderServiceWithLocator() # constructs fine, nothing looks wrong yet
try:
print("locator:", svc.total("o1"))
except KeyError as e:
print("locator failed at call time, missing:", e)
Registry.register("order_repo", FakeOrderRepo())
print("locator after global setup:", svc.total("o1"))
Output:
constructor: 1550
locator failed at call time, missing: 'order_repo'
locator after global setup: 1550
The constructor version tells you from its signature that it needs a repository, and a test simply passes a fake (1,200 + 350 = 1,550). The locator version constructs without complaint and fails only when total runs; to test it you must mutate global state, which leaks between tests that run in the same process.
Advantages of a DI framework in the service and repository layers
- Wiring large object graphs. A container (Spring, .NET's built-in container, Guice, Dagger) builds hundreds of objects in the right order so you do not hand-write the assembly code.
- Lifecycles and scopes. It manages which objects are shared for the whole application (singleton), created per request, or created each time.
- Environment-specific configuration. Swap the real payment client for a sandbox client in staging through configuration, not code changes.
- Declarative cross-cutting behaviour. Transactions or metrics applied by annotation (a marker attached to code, such as
@Transactional, that tells the framework to wrap it with extra behaviour automatically) around service methods. - Testability. Because services receive their repositories, tests hand them fakes, and the business logic never needs a database to be exercised.
Pitfalls to watch for
- Framework leaking into the core. Annotations and container types in domain classes couple your business rules to the framework; hexagonal and clean architecture (architectural styles that keep business rules as plain code with no framework dependencies, pushing frameworks, databases and other details to the outside) both require that dependencies point inward, toward framework-free code. Keep the core as plain objects and do the wiring in one composition root (the single place at startup where the object graph is assembled).
- Scope mismatch. A singleton service that receives a request-scoped object (one the container creates fresh for each incoming request and discards afterward) at startup keeps that one instance forever, so data from one request can bleed into another. Containers offer proxies (a lightweight stand-in injected into the singleton that, on every call, fetches whichever request-scoped instance is current) or providers (a small factory object injected instead of the real dependency, which the singleton calls each time it needs a fresh, current instance) for this; you have to know to use them.
- Circular dependencies. With constructor injection, a cycle (A needs B, B needs A) cannot be constructed and the container fails at startup, which is good: it exposes a design problem. Setter or field injection can let the container paper over the cycle.
- Hidden god classes (classes that quietly accumulate far more responsibility than they should, because adding one more dependency is so easy). Adding a dependency costs one more constructor parameter, so bloat creeps in silently. A service with 10 or more injected collaborators is a cohesion smell (a warning sign that a class is doing too many unrelated things to be well designed); split it.
- Magic and startup cost. Classpath scanning (the container inspecting every class in the application at startup to find the ones it should manage) and reflection (code that inspects or calls into other code at run time, by name, rather than at compile time) make it hard to answer "where does this object come from?" and slow startup, which matters for serverless (functions that start fresh for each invocation and shut down between them, so every millisecond of startup cost is paid repeatedly) or short-lived processes. Compile-time DI (Dagger) (wiring code generated when the project is built, instead of discovered by scanning and reflection at startup) or plain manual wiring avoids both.
- Interfaces for everything. Creating an interface for every class "for DI" adds ceremony. Introduce an interface where there is a real second implementation or a test double worth having.
Recommendation: constructor injection everywhere in service and repository code; a container only at the outer layer; plain manual wiring for small services, where a 20-line composition root is clearer than a framework.
A developer platform is adding support for third-party plugins that execute arbitrary code. Two isolation strategies are on the table: sandboxed isolated runtimes (like WebAssembly or containers) and running plugins in-process behind a restricted API. Walk through the trade-offs (think: security blast radius, performance, and the developer experience of building and debugging a plugin) and recommend an approach, distinguishing internal trusted plugins from external third-party ones.
Sample Answer
Direct answer
Split by trust, because the two options solve different problems. Internal, trusted plugins (written by our own teams, code-reviewed, deployed through our pipeline) run in-process behind a restricted API: the fastest option with the best debugging experience, and the "restriction" there is about keeping a clean contract, not about stopping an attacker. External third-party plugins that execute arbitrary code run in an isolated runtime: WebAssembly (Wasm, a portable low-level bytecode format that runs inside a sandboxed virtual machine embedded in the host process, with its own private block of memory that code running inside it cannot see or reach outside of, including the host process's own memory) for short, synchronous hooks, and containers (ideally microVM-backed) for plugins that need a full language runtime, native libraries or long-running work. The reason is blast radius: in-process, a hostile plugin shares the platform's memory, credentials and network identity, and a restricted API cannot reliably fence off code that runs in the same process.
The key idea: an API boundary is not a security boundary
A "restricted API" means the plugin is only handed certain objects (say, a read-only order and a logger). But arbitrary code running in the same process can usually reach around the API: reflection (a language feature that lets running code inspect and call into other objects' internals directly, bypassing whatever the restricted API meant to hide), importing modules directly, reading environment variables that hold database credentials, opening sockets, or simply allocating memory until the process dies. Language-level sandboxes have a poor track record here. Java is the clearest case: its in-process sandbox, the Security Manager, was deprecated for removal in Java 17 (JEP 411) and permanently disabled in JDK 24 (JEP 486). A JEP (Java Enhancement Proposal) is Java's formal process for proposing, discussing and shipping a language or platform change, so both numbers mark real, dated removals, not a proposal that stalled. Python and Node.js have never offered a supported in-process sandbox for untrusted code.
So for third-party code, isolation must come from a boundary the plugin cannot program its way through: a separate memory space (Wasm linear memory: a single contiguous block of memory a Wasm module can address, so the module can only ever read or write inside that block and has no way to name or reach any memory outside it, including the host's), or a separate process with kernel-enforced limits (a container), or a separate virtual machine kernel (a microVM, a lightweight virtual machine such as Firecracker that boots a minimal guest kernel). Inside its linear memory, a Wasm module can only call the specific host functions the host chose to expose to it (named operations such as "read this field" or "write this log line"); everything not explicitly granted is refused by default (deny-by-default host access), which is why the module has no path to the filesystem or the network unless the host built one on purpose.
Comparing the options
| Dimension | In-process, restricted API | Wasm sandbox | Container / microVM |
|---|---|---|---|
| Security blast radius | Whole process: platform memory, secrets, network identity, every tenant's data in memory | One instance's linear memory; host functions only as granted | One container; kernel-shared for containers (the containers on a host all run on top of that one host kernel, so a kernel bug or an escape can reach every container on it), separate guest kernel for microVMs (each microVM boots its own minimal kernel, so one guest's compromise does not reach another guest or the host) |
| Resource control | Weak: one plugin's infinite loop or memory leak degrades the host | Strong: memory cap per instance, CPU metered (for example Wasmtime's fuel or epoch interruption) | Strong: cgroup limits (the Linux kernel feature that caps a group of processes' CPU and memory), network policy |
| Call overhead | Function call | Low: a call into a pre-compiled module, plus copying inputs across the boundary | High: a network or IPC (inter-process communication) round trip per call, plus startup cost when scaling from zero (booting a fresh container instance because none was already running idle to take the call; this cold start, not the steady-state call, is usually the biggest latency cost of the container path) |
| Language support | Platform's language only | Languages that compile to Wasm; mature and fast for statically-compiled languages, chiefly Rust and Go (via TinyGo, a Go compiler variant built for small, constrained targets like Wasm, or recent standard Go), plus C/C++; AssemblyScript (a TypeScript-like language designed to compile to Wasm) covers JavaScript-style authors. Dynamic languages such as Python only run via an embedded interpreter compiled to Wasm itself, which gives back much of the performance advantage, so that row matters most for whether your third-party authors are already writing in a compiled language | Any language, any library |
| Debugging experience | Best: normal debugger, stack traces, same logs | Weakest: step-through debugging support is limited; authors rely on a local runner and structured logs | Good: familiar tools, but distributed (logs and traces across a network hop) |
| Operational cost | None extra | Moderate: runtime embedding, SDK, host-function design | Highest: scheduling, image scanning (automatically checking a container image for known-vulnerable software before it is allowed to run), networking, scaling |
Recommendation, and what flips it
- Internal trusted plugins: in-process behind the restricted API. The threat is bugs, not malice, and bugs are handled by code review, tests, and the same deploy pipeline as everything else. I still keep the restricted API (plugins receive a context object, never the database client) because it keeps coupling low and makes a later move out-of-process cheap.
- External third-party plugins on the request path: Wasm. Most plugin work (validate this record, transform this payload, compute a score) is short, CPU-bound and stateless. Wasm gives near-in-process call cost with a real memory boundary and deny-by-default host access.
- External plugins that need a real runtime, native dependencies, or run for seconds to minutes: containers, ideally microVM-isolated, called asynchronously through a queue rather than inline, so their startup and network cost does not sit on a user's request.
What would flip it:
- If third-party authors overwhelmingly need Python with native libraries (NumPy, pandas), Wasm support is too thin today and containers become the default external path.
- If the platform is single-tenant and customers only run their own plugins against their own data, the blast radius of in-process shrinks to "the customer hurts themselves", and a separate process per customer may be enough.
- A "trusted" internal plugin that parses untrusted input (file uploads, third-party webhooks) should be treated as external, because the attacker controls its input.
Worked example: blast radius and cost of one hook
Scenario: a document-processing platform lets partners add a transform_record hook, called 2,000 times per second at peak.
- In-process: a partner plugin reads the process environment and finds the database URL with credentials. Every tenant's data is now exposed. Blast radius: the entire platform.
- Wasm: the same code tries to read the environment; the host did not grant that function, so the module fails to instantiate or the call traps (the Wasm runtime aborts that one call with an error the instant it tries to do something outside what it was granted, instead of letting it run past the boundary). Blast radius: that partner's plugin fails, the core applies the hook's failure policy.
- Container per call: isolation is strong, but 2,000 synchronous network round trips per second to partner containers means operating a fleet sized for peak, plus a network hop on every record. At that rate a synchronous container call is the wrong shape; containers fit this platform only for asynchronous batch plugins.
The arithmetic that drives the choice is the call rate times the per-call boundary cost, compared with the latency budget. Concretely: the boundary itself, separate from whatever work the plugin actually does, costs roughly 0.001 ms per call in-process (a native function call), roughly 0.02 ms per call for Wasm (a call into a pre-compiled module plus copying the input across the linear-memory boundary), and roughly 2 ms per call for a warm container reached over the network. At 2,000 calls per second, in-process and Wasm add about 2 ms and 40 ms respectively of aggregate boundary overhead per second of traffic, both negligible next to almost any request-level latency budget. A warm container adds about 2,000 x 2 ms = 4,000 ms of round-trip time to service per second of traffic, which by itself means roughly 4 container instances have to be running concurrently just to keep up with the round trips (concurrency needed = arrival rate x time per call), before counting the hundreds of milliseconds a cold start adds to whichever request triggers one. A boundary crossing that is fine at 10 calls per second becomes the dominant cost at thousands.
Developer experience: make the safe option pleasant
Choosing Wasm for externals only works if authors can build and debug plugins without pain:
- Ship an SDK per supported language that hides the boundary (typed input and output instead of raw bytes).
- Ship a local runner binary that behaves exactly like production: same limits, same host functions, same error messages on a timeout or a denied capability.
- Return structured errors ("plugin exceeded 5 ms CPU budget", "capability
httpnot granted") and let authors see their own plugin's logs, never the platform's.
Pitfalls
- Calling a restricted API "sandboxing". It is a design boundary, not a security one, and treating it as security is the most common senior-level mistake on this question.
- One sandbox for everything. Forcing long-running, dependency-heavy plugins into Wasm, or putting every tiny hook behind a container, each fails a different workload.
- Forgetting the data boundary. Isolation of code is useless if the host passes the plugin more data than it needs. Pass the minimum snapshot for that tenant and hook.
- Ignoring the supply chain. Even sandboxed plugins should be signed and scanned; the sandbox limits damage, it does not prevent a plugin from returning wrong answers.
What is a 'distributed monolith' anti-pattern? Describe two signs that a microservices deployment has effectively become a distributed monolith, and propose one concrete mitigation step.
Sample Answer
Direct answer
A distributed monolith is a system that is split into separately deployed services but still behaves like one program: the services cannot be changed, tested or released independently. You pay every cost of a distributed system (network latency, partial failures, harder debugging) and get none of the main benefit, which is independent teams shipping independently. Two strong signs are lockstep deployments (a feature routinely requires several services to release together, in a specific order) and long synchronous call chains (one user request cannot complete unless many services are all up at the same moment). A concrete first mitigation is to make deploys independent with versioned, backward-compatible APIs checked by consumer-driven contract tests, so each service can ship without waiting for the others.
What it looks like
Think of an online store split into Cart, Pricing, Inventory, Orders and Payments. On paper these are five microservices. In practice:
- Adding a "gift wrap" option means changing all five, and the release plan is a spreadsheet saying "deploy Pricing first, then Orders, then Cart, within 30 minutes, or checkout breaks".
- Placing an order calls Cart → Pricing → Inventory → Orders → Payments, one after another, synchronously.
- Orders and Inventory read and write the same
productstable.
Sign 1: lockstep deployment
What you observe: releases are coordinated across services; there is a shared release train (a fixed, recurring release schedule that several services are bundled onto and ship together, rather than each shipping on its own whenever its change is ready) or a "deploy order" document; rolling back one service requires rolling back others.
Why it happens: services share a data model or API that changes without versioning. If Pricing renames a field in its response, Cart breaks until Cart is updated, so they must deploy together.
How to detect it: from the deploy log, compute the fraction of releases in which a service was deployed within the same short window as another service for the same ticket. If Cart ships with Pricing in most of its releases, those two are one deployable in disguise.
Sign 2: synchronous call chains that multiply failure
What you observe: a distributed trace (a record, stitched together from the timestamped spans each service emits for the same request, showing every service that request touched and how long each one took) of one request shows a deep chain of blocking calls, and an outage in any one service takes down the whole user flow.
Why it matters, with numbers: if a request needs 5 services in series and each is independently available 99.9% of the time, the chain is available only when all 5 are up:
- 0.999 × 0.999 × 0.999 × 0.999 × 0.999 = 0.999^5 ≈ 0.99501, about 99.50%.
- Over a 30-day month (30 × 24 × 60 = 43,200 minutes), one service at 99.9% is down about 0.001 × 43,200 = 43.2 minutes.
- The 5-service chain is down about (1 − 0.99501) × 43,200 ≈ 215.6 minutes, roughly five times as much.
A monolith would have had one component's worth of downtime; the distributed monolith has five, with the same features. (This simple model assumes failures are independent; correlated failures change the number but not the lesson.)
Other signs worth naming
- Services sharing a database schema, so a column change needs coordination.
- A shared library of domain objects that every service must upgrade together.
- End-to-end tests that need every service running before anyone can merge.
The mitigation: break deploy coupling first
Why start here: lockstep deploys are the most expensive symptom (they slow every team, every release) and fixing them is incremental. Steps:
- Version the contract. API changes become additive: add a new field, never rename or remove one in place. Consumers use a tolerant reader (ignore fields you do not recognise, do not fail on missing optional ones).
- Add consumer-driven contract tests. Each consumer (Cart) publishes the exact requests and response fields it depends on; the provider (Pricing) runs those contracts in its own CI. Tools such as Pact implement this. Now Pricing learns in its own build, before deploying, whether it would break Cart.
- Deploy in expand-then-contract order. To rename
pricetounit_price: Pricing ships a version returning both; Cart switches tounit_pricewhenever it likes; once contracts show no consumer readsprice, Pricing removes it. Three independent deploys, no coordination window. - Measure the result. Track the lockstep share from the deploy log. If Cart and Pricing co-deployed in 8 of 10 releases before, the target is near zero within a couple of quarters.
If the contracts reveal that two services cannot evolve separately no matter what (they change together on every feature), the honest fix is to merge them back into one service. A boundary that always moves together was never a real boundary.
Pitfalls
- Fixing the symptom with tooling. A better release-orchestration tool makes lockstep deploys easier to run, which hides the coupling instead of removing it.
- Adding more services. Splitting further to "fix" coupling usually adds more edges.
- Replacing sync calls with async messages carrying the same shared data model. The deploy coupling survives; it just moved into the message schema.
Unlock Full Question Bank
Get access to all 21 Architectural Patterns and Anti-Patterns interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.