skip to content

Treating the GRASP controller as the single entry point per use case makes it a natural boundary for cross-cutting policy. Which concerns belong at that boundary (transactions, authorization, idempotency, tracing, validation), and what goes wrong when they are placed above it in the delivery adapter or below it in the domain?

level: principalimportance: nice to knowfreq 28%

answer

  1. single entry point ⇒ uniform policy for every caller
  2. AT: transaction, authorization, idempotency, span/audit
  3. ABOVE: binding, authentication, protocol mapping
  4. BELOW: invariants — must hold for all callers
  5. enforce with arch tests, not convention

basics

~20 s

The controller is the one place every caller passes through, so transaction start/commit, permission checks, idempotency, and tracing spans fit there. Put them in the HTTP layer and non-HTTP callers skip them; put them in entities and they get duplicated and tangled.

solid answer

~50 s

Because a use-case controller is the single non-UI entry point for an operation, it is the only place where a policy applies to *every* caller — HTTP, queue consumer, scheduled job, CLI, test. Belongs there: the **unit-of-work / transaction boundary** (the use case defines what one consistent operation is); **authorization** on the operation and its resources (so non-HTTP channels inherit it); **idempotency** keyed on a command id (retries arrive from any channel); **tracing/metrics spans and audit** named after the business operation, not the URL; and **orchestration-level validation** (does the referenced entity exist, is the command coherent). Above it (adapter): syntactic binding/format validation, authentication, protocol error mapping. Below it (domain): invariants and business rules, enforced regardless of who calls. Failure modes: policy in the adapter is silently bypassed by every other channel; policy in the domain gets duplicated across entities, couples them to infrastructure, and produces nested/partial transactions. The controller boundary must be enforced by convention plus tooling, or handlers will drift.

code

pseudocode · 8 lines
pseudocode
// Policy applied uniformly by wrapping every handler — order matters
handler = Traced(
            Authorized(
              Idempotent(
                Transactional(PlaceOrderHandler(...)))))

// arch test: no handler may be registered outside the pipeline
assert allCommandHandlers().all { registry.pipelineFor(it).contains(Authorized) }

go deeper

for a junior

Say that having one entry point per operation gives a single place for shared concerns like starting a transaction and checking permissions.

for a middle

Assign each concern to adapter / controller / domain with a reason, and note that HTTP-layer-only authorization is skipped by non-HTTP callers.

for a senior

Add mechanism (explicit code vs decorator pipeline vs AOP), ordering constraints, and concrete failure modes: bypass, double application, transaction across a remote call.

for a principal

Argue the boundary as an evolvability and risk policy: uniformity across channels, executable enforcement in CI, protocol-neutral observability and error taxonomy, outbox/saga where one transaction cannot span the operation, and an explicit cost/benefit stance on when the layer is not worth it.

## Why the controller is a boundary at all GRASP Controller says a system event is received by a non-UI coordinator. If that coordinator is **the only** way to invoke the operation, it becomes a *choke point* — and choke points are where cross-cutting policy can be applied once, uniformly, for every caller: web, mobile BFF, queue consumer, scheduled job, admin CLI, data migration, test. That uniformity property is the real architectural value, beyond mere tidiness. The design question is then: for each concern, does it belong **above** (delivery adapter), **at** (use-case controller), or **below** (domain)? ## Concern-by-concern placement ### Transactions / unit of work — AT the controller Only the use case knows what constitutes *one consistent business operation*. Placement failures: - **Above (adapter):** the transaction is scoped to the HTTP request; a queue consumer running the same use case gets none, or you re-implement it per channel. Open-session-in-view-style patterns also keep transactions open across view rendering, causing long-held locks. - **Below (domain/repository):** each repository call becomes its own transaction, so a multi-step operation can half-succeed; propagation settings then get tuned per method until nobody can predict the actual boundary. - **At the controller:** one explicit boundary; nested calls join it. Note the corollary — one use case should generally be **one** transaction; if you need two, you probably have two use cases, or you need sagas/outbox because a remote call is involved. ### Authorization — AT the controller (mostly) Distinguish **authentication** (who are you — adapter's job, protocol-specific: bearer token, mTLS, session cookie) from **authorization** (may this identity perform this operation on this resource). - **Above:** URL/route-based permission rules are bypassed the moment another channel invokes the handler. This is a genuine security failure mode, not a purity concern — internal callers routinely skip the web tier. - **Below:** entities start needing a `currentUser`, which drags ambient security context into the domain and makes rules untestable in isolation. - **At:** the handler checks operation-level permission and resource ownership before delegating. Fine-grained *data* filtering (row-level scoping) often still belongs in the query/repository layer, driven by a scope passed down explicitly. ### Idempotency — AT the controller Retries arrive from any channel: HTTP client retries, queue redelivery, a user double-clicking. Keying idempotency on a client-supplied command/request id at the handler makes replay-safety a property of the use case rather than of HTTP. Placing it in the adapter (e.g. only honouring an `Idempotency-Key` header) leaves queue redelivery unprotected — and at-least-once delivery guarantees mean redelivery *will* happen. ### Tracing, metrics, audit — AT the controller Spans and audit records named after the **business operation** (`PlaceOrder`) survive URL changes, API versioning, and channel additions, and are comparable across channels. Adapter-level instrumentation names things after routes; domain-level instrumentation is too fine-grained and floods cardinality. ### Validation — split deliberately Three distinct kinds: 1. **Syntactic/format** (required field, well-formed date, string length) → adapter, so bad input is rejected before any domain object exists. 2. **Orchestration-level** (referenced entity exists, command internally coherent, referenced ids belong to the same tenant) → controller. 3. **Invariants/business rules** ("balance may not go negative", "a closed account cannot be debited") → domain, always, because they must hold no matter who calls. A rule enforced only at the controller is a rule that a future second caller can violate. ### Concerns that belong elsewhere - **Rate limiting / quota:** usually the edge (gateway), because you want to shed load *before* work begins. A per-tenant business quota, though, is a domain rule. - **Retries of outbound calls:** the gateway/adapter that owns the remote call. - **Serialization, caching headers, content negotiation:** adapter, always. ## Mechanism: how policy gets applied - **Explicit code in each handler** — obvious and debuggable, but repetitive and easy to forget; a forgotten authorization check is a vulnerability, not a style issue. - **Decorators / middleware around handlers** — a uniform pipeline (`Authorize(Transactional(Idempotent(handler)))`). Uniform by construction, but the control flow becomes indirect and ordering matters (authorize before transaction; idempotency check before side effects). - **Framework AOP / annotations** — least code, but proxy-based implementations have notorious edge cases (self-invocation bypassing the proxy, final/private methods, unclear ordering). Whichever mechanism, **enforce it**: an architecture test asserting every command handler is registered in the pipeline (or carries the required annotations) turns "we agreed to always do this" into something CI can fail on. Convention without a check erodes within a few sprints. ## Failure modes to name in an interview 1. **The bypass.** A second channel calls the domain or the repository directly, skipping every policy. Guard with dependency rules (only handlers may be called by adapters; repositories are not public API). 2. **Double application.** Both adapter and handler authorize, with subtly different rules; the effective rule becomes whichever is stricter, and nobody knows which. 3. **Transaction spanning a remote call.** The handler holds a database transaction open while calling a third party — locks held for the remote latency, and no atomicity anyway. Use outbox/saga instead. 4. **Anemic domain by accident.** Once the handler is the policy hub, developers put business rules there too, and the domain degrades into data holders — the bloated controller returns by a different route. 5. **Cardinality/observability drift.** Spans named by URL rather than operation, so a route rename breaks dashboards. ## The trade-off, honestly stated This discipline costs a layer, a mapping step (command objects, result DTOs), and enforcement machinery. In a single-channel service with a small team, the cost may exceed the benefit for read paths. The asymmetry to reason from: the cost of the layer is paid continuously and visibly; the cost of *not* having it appears all at once, on the day a second channel or a security review arrives — and is paid in duplicated policy and missed checks.

  • Why is route-based authorization in the HTTP layer insufficient?
    It only protects the HTTP path. A queue consumer, scheduled job, CLI, or another in-process caller invoking the same handler bypasses it entirely — so the check must sit where every caller passes, at the use-case boundary.
  • What if one use case seems to need two transactions?
    That usually means it is really two use cases, or it involves a remote call that must not sit inside a database transaction. Split it, and use an outbox or saga for cross-boundary consistency rather than stretching one transaction.
  • How do you stop the policy-hub controller from becoming a bloated controller again?
    Keep invariants in the domain and let the handler hold only sequencing plus policy; apply policy via a uniform decorator pipeline instead of hand-written code; and enforce both with architecture tests plus a constructor-arity/complexity cap so drift fails CI.
  • Where does rate limiting belong?
    Infrastructure rate limiting belongs at the edge so load is shed before work starts. A business quota ('this tenant may create 100 orders per day') is a domain rule and belongs with the data that tracks it.

Airport security is placed at one checkpoint, not at each aircraft door (too late, duplicated) and not at the city entrance (too early, wrong scope). One controlled passage that every traveller must cross is what makes the policy actually universal.

saying these in an interview costs you the question

  • Relying on URL/route-based permission rules as the only authorization, leaving non-HTTP callers unchecked
  • Holding a database transaction open across a remote HTTP call to get 'atomicity'
  • Implementing idempotency only from an HTTP header, so at-least-once queue redelivery is unprotected
  • Pushing invariants up into the controller and leaving the domain as data holders — bloat returning by another route
  • Applying the same policy in both adapter and controller with divergent rules
  • Assuming an AOP annotation always applies, ignoring proxy pitfalls such as self-invocation bypassing the interceptor
  • Treating the boundary as guaranteed by convention with no architecture test enforcing it

context