Keep Database Access Behind a Use-Case Boundary
A reliable way to simplify API routes is to move persistence reads into small use-case services while preserving authorization scope, transaction behavior, and testable seams.
Flow
A safe migration loop
1Inventory
List direct queries, scope checks, transactions, and response behavior.
2Extract
Create one narrow use-case service with explicit fields and predicates.
3Replace
Swap one route or command call while preserving its external contract.
4Verify
Run focused tests for access, scope, clients, locks, errors, and payloads.
5Ratchet
Add a structural check so the old dependency cannot return unnoticed.
A safe refactor starts with an inventory, not a new folder. Search the route area for direct database imports and list each query by the question it answers. Group queries that share scope and purpose, such as order summary, current submission, artifact download, or revision send. Record the current authorization check, selected fields, parent constraints, error mapping, transaction use, and side effects. This inventory is the baseline against which the extraction can be reviewed.
Create a small service around one question. A read function might accept an order identifier and a submission identifier, then constrain the submission by both values. It should select only the artifact body, content type, filename, and the owning account needed for the access decision. It should not return a complete ORM object merely because that is convenient. A deliberately narrow projection makes accidental data exposure and accidental coupling harder.
Change one route at a time. Keep parameter validation and the authorization decision in the route if they depend on request context. Replace the inline query with the service call. Preserve the existing status codes, response headers, filename sanitization, and not-found behavior. If the route needs an owner for access control, let the service return the owner from the same scoped query rather than issuing a broad lookup later.
Commands deserve a slightly different treatment. Suppose a send operation currently receives a database client from every route. Make the client optional at the command boundary, choose the shared client once when it is absent, and use the chosen client for every loader, lock, transaction, and persistence call. A caller that already owns a transaction can still inject its transaction client. Do not let individual helpers silently fall back to a different client, because that breaks atomicity in a way that tests may miss.
After each replacement, compare behavior rather than just types. Run the focused route and service tests. Add a test for the exact scope predicate, including the parent relation. Add a route test that proves the access check occurs before the use-case call. Add a command test that proves default-client behavior and another that proves an injected transaction client remains in use. For a file or document response, test headers and content handling as well as the database result.
A structural guard is worthwhile once the first slice is stable. The guard can reject a direct database import from the route directory or maintain a small exception list for legacy code that is still being migrated. Treat the list as a ratchet: new violations fail immediately, while the remaining list shrinks through deliberate changes. The guard should describe an architectural intent, not become a complicated second compiler.
Watch for common mistakes. A service that accepts an unbounded filter object is only a generic query wrapper. A service that returns a full row encourages callers to depend on storage fields. A route that calls the new service before authorization still has a data-leak risk. A transaction client that is injected into one loader but not the next can split a supposedly atomic operation. A broad parent query followed by an in-memory filter can also be slower and less safe than a single scoped predicate.
Performance belongs in the review. Compare query count, selected columns, indexes, and execution plans before and after. If several routes need the same summary, a shared service can reduce drift without requiring one giant read model. If a route needs a large binary, keep the authorization lookup narrow and stream the payload through a deliberate boundary. If a command crosses an external side effect, document which state is committed before the side effect and which state is recovered afterward.
Finish with a migration review that asks four questions: can an unauthorized caller learn anything, can a child record escape its parent scope, can a transaction lose its lock or client, and can future code reintroduce the old coupling? If the answers are covered by code, tests, and a structural check, the extraction is more than rearranged files. It is a new, enforceable ownership boundary.