The Only Three Design Patterns You Need to Keep Shipping Fast
Unopinionated frameworks offer freedom—until that freedom becomes a tangled mess. Here are three battle-tested patterns that keep your delivery speed sustainable for the long haul.
Unopinionated frameworks are a dangerous seduction.
When you start a new project with Express, that freedom feels intoxicating. No forced conventions, no "magic" you don't understand, no fighting the framework. It feels great—for about three months.
Then the Freedom Tax comes due.
Business logic leaks into route handlers. A "utils" folder metastasizes into a junk drawer. Testing becomes a nightmare because your logic is hopelessly coupled to your HTTP layer. What started as "simple and flexible" becomes a tangled mess that grinds development to a crawl.
Having built backends across the spectrum—from opinionated batteries-included frameworks (Rails, Phoenix) to the Wild West of micro-frameworks (Express, Java or Python Lambdas)—I've noticed the survivors all share the same DNA.
In this post, I'll share three architectural patterns I now apply to every backend project. They're simple, framework-agnostic, and they make the difference between a codebase that scales and one that drowns in technical debt.
Pattern #1 – Separate Domain and Presentation Layers
Your presentation layer is anything that interfaces with the outside world. HTTP handlers, worker handlers, CLI commands, GraphQL resolvers—whatever entry point your application exposes. Your domain layer is where the actual business logic lives.
The rule is this: your presentation layer should be lean. It receives a request, translates it into something your domain understands, calls the domain layer to do the real work, and returns the result. That's it. No business logic. No complex conditionals. Just translation and delegation.
Business logic that lives in your presentation layer is trapped there.
Imagine you build a feature that lets users upgrade their subscription via an API endpoint. The logic is all in the HTTP handler—it checks eligibility, calculates prorated charges, updates the database, and sends a confirmation email. It works fine.
Three months later, the product team wants to trigger the same upgrade flow from a background worker when a trial expires. Now you have a problem. You can't reuse the logic because it's tangled up with HTTP request parsing, response formatting, and framework-specific code. So you either duplicate the logic (and now have two places to maintain) or you do a painful extraction under time pressure.
This isn't about dogma—it's about keeping your options open. When business logic lives in the domain layer, it can be called from anywhere: an HTTP handler today, a worker tomorrow, a CLI tool next month. When it's buried in your presentation layer, every new entry point becomes a refactoring project.
Keep your handlers thin. Push the logic down. That's how you avoid paying the Freedom Tax twice.
Pattern #2 – Split Your Domain into Slices
Once you've separated your presentation and domain layers, a new question emerges: how do you organize the domain layer itself?
There are two main schools of thought:
- Fat models: Your model classes carry both data and behavior. They often know how to persist themselves. This is the Active Record pattern you see in Rails and Django.
- Thin models + services: Your model classes only carry data. Behavior lives in separate service classes.
The architecture I'm proposing follows the second approach, heavily inspired by Phoenix's bounded contexts (Phoenix's term for self-contained domain modules that own their own data and behavior). I've found this pattern easy to reason about, hard to accidentally violate, and well-suited to unopinionated frameworks like Express.
Here's the structure: your domain layer contains multiple slices. Each slice owns a service, a repository, and its database schemas or models. A slice implements a set of tightly related use cases within a specific area of your domain.
The key insight: design your slices around what your system does, not what it stores.
In an e-commerce app, for example, you might have Orders, Inventory, Billing, and Notifications. Each slice owns its use cases and its data. Orders doesn't reach into Billing's tables; it calls Billing's service.
Here's a simple process I use to find the right slices:
- Write down your high-level use cases.
- Think of your application as a company with departments. Assign each use case to one—and only one—department. These departments are your slices.
- If a use case feels like it belongs to two departments, one of three things is true: your departments are poorly defined, it's actually two use cases, or one department owns the orchestration and calls the other.
- For each department, ask: "What data does this department exclusively own?" That's its schema.
When you organize your domain this way, adding a new feature becomes obvious. You know exactly which slice it belongs to, where the service method goes, and what data it touches. No ambiguity, no "where should I put this?" debates.
The architecture answers the question before anyone has to ask.
Pattern #3 – Use Dependency Injection
Dependency injection might be the most unfairly skipped pattern in backend development. Developers hear "DI" and picture verbose annotation-heavy frameworks, XML configuration files, or abstract factory factories. The enterprise Java trauma is real.
But here's the thing: those frameworks are solving a problem you might not have. The pattern itself is dead simple—and you can implement it with nothing but constructor arguments.
Here's the core idea: how your classes and functions access their dependencies matters enormously for your long-term delivery speed. There are two ways to wire your code together:
1. Internal wiring (tight coupling)
You import a dependency at the top of a file and use it directly. It looks harmless, but you've just hardwired your class to that dependency.
// Tight coupling
import { userRepository } from "../repositories/userRepository";
class UserService {
getUser(userId: string) {
return userRepository.findById(userId);
}
}2. External wiring (dependency injection)
You pass dependencies into your class via its constructor. They're still connected, but you can swap them out anytime.
// Dependency injection
interface UserRepository {
findById(userId: string): Promise<User | null>;
}
class UserService {
constructor(private userRepository: UserRepository) {}
getUser(userId: string) {
return this.userRepository.findById(userId);
}
}That's it. No decorators. No framework. No magic. Just pass your dependencies in.
The difference looks trivial. It's not.
Why dependency injection matters:
- Testability. When dependencies are injected, you can pass in fakes or mocks during testing. No monkey-patching, no setup gymnastics, no spinning up real databases. You test your business logic in isolation.
- Flexibility. Need to swap your Postgres repository for a DynamoDB one? Change the wiring in one place. The service doesn't know or care.
- Explicit dependencies. A class's constructor tells you exactly what it needs. No hidden dependencies buried in import statements. No surprises.
- Layer support. Remember Pattern #1? Dependency injection is what makes it practical. Your HTTP handler receives a service; the service receives a repository. Each layer only knows about the layer below it through an injected interface, not a hardwired import.
The small upfront cost of wiring dependencies explicitly pays dividends every time you write a test, swap an implementation, or onboard a new teammate. It's the kind of investment that compounds—and it's a big part of how you avoid the Freedom Tax altogether.
The compound effect
These three patterns—separating domain and presentation layers, designing around behavior, and using dependency injection—work together as a system.
Separation keeps your business logic reusable across any entry point. Behavior-driven slices organize your codebase around what users actually do. Dependency injection keeps your layers loosely coupled and testable. Each pattern reinforces the others.
That's how you stop paying the Freedom Tax.
I'm building a lightweight TypeScript framework based on these patterns—no decorators, no magic, just the structure that makes teams fast. I'll be sharing it in the coming weeks, along with implementation details and practical examples.