Software design prepares you to use AI. Here’s how.
Architecture enables structures to grow without collapsing under the weight of their own complexity. Software design is the way we architect code in an application.
While agentic coding is quite capable at generating code, we rely on software design to make things secure, scalable, well-integrated and affordable. Without design, as the codebase grows bigger and the context becomes more complex, even the AI won’t be able to make a change without monumental token use costs and breaking other parts of the app. So, you could say software design is needed now more than ever!
Clean Architecture vs. Layered Architecture
Two architectures that I leverage as reference for software design are Clean Architecture and Layered Architecture. Despite some differences, each offer patterns that strive for the same goals:
- Separation of Concerns
- Maintainability
- Independence
- Testability
- Directional Flow
Here’s more about Clean Architecture and Layered Architecture – including a comparison – and how we make use of each in software design. I’ll compare both and provide some code examples.
Clean Architecture
Clean Architecture, and cousins like Hexagonal Architecture, are software design patterns which have lots of good qualities. One of the main benefits is that the domain is well-defined, independent, testable and scalable.
I wrote ‘cousins’ because Clean Architecture builds on many of the same ideas as Hexagonal Architecture. Clean Architecture tends to be stricter about boundaries and domain modeling, but in practice the two architectures have similar patterns, and offer many of the same advantages and tradeoffs. In this article, I’ll refer to both of them simply as “Clean Architecture” for simplicity – just keep in mind that I’m talking about both patterns.

As shown in the diagram, an important rule for this way of organizing code is that all dependencies must point inward, from the outer layer towards the innermost layer.
You can read each arrow as “depends on” or “uses”.
In practice, this means your application could be made up of layers which depend on each other in the following way:
Web -> Adapters -> Use Cases -> Ports -> Models
The Web and Adapter layers live outside the Domain, while the Use Cases, Ports, and Models make up the Domain itself.
You can think of the web layer as a generic framework route, such as a GET request managed by Express.js. We’ll get into the details of the layers later, so don’t worry too much about what they do yet. What matters here is the direction of the arrows. They go only one way.
Clean Architecture allows us to replace parts of the app if we need to. For example, we can share some code for a mobile app rather than a web app or change from Postgres to an API as a data source, without changing any business logic. We do this by wrapping all interaction with the outside world in a layer called Adapters, while the Domain exposes Ports (interfaces) that define what the Domain needs. Because the Domain layer depends only on these Ports, not on the Adapters, we can change Adapters (databases, APIs, email services, etc.) without having to change the Domain at all.
This is why Hexagonal Architecture is often called “Ports and Adapters”: The Domain defines what it needs through Ports, and the Adapters outside the Domain implement those contracts.
This organization keeps the Domain independent from frameworks and databases. That’s why the direction of the arrow matters. If the Domain depended on the Adapter, then changing the adapter would force changes in the Domain as well.
These rules aren’t arbitrary. They come directly from the SOLID design principles, which Clean Architecture applies at the architectural level.
The Domain is the raw business logic of our app, independent of any database or framework. It has logic such as “if the title of a new blog post is empty then it’s an invalid post”, or “when a post is published, I will notify its subscribers”.
One of the biggest advantages is that it lets you test your business logic without needing a database or web framework. It also scales beautifully. It’s good for big projects with lots of developers and lots of development time.
It all sounds great on paper, and a lot of it is, but the moment you try to use Clean Architecture in a real project, the friction becomes obvious.
The Cost of Strictness
Back when I learned Clean Architecture, I followed a strict interpretation of the pattern, largely inspired by the Get Your Hands Dirty on Clean Architecture book. It’s written about Java, but adapting the ideas to your stack of choice can be a fun exercise.
While it worked exactly as described, the experience wasn’t without friction. The biggest downside is simple: you need to write more code to get things done. A lot more code.
Showing concrete examples is always tricky when talking about software design. Discussions tend to drift into abstraction, which is one of the challenges of the Clean Architecture book itself. But the overhead becomes much clearer when you compare strict Clean Architecture to the simpler model most teams already use in one way or another: Layered Architecture.
Layered Architecture
Layered Architecture is simple: You define a few layers, give each one a responsibility, and place your code accordingly. You can have as many layers as you need, and the dependency arrows usually flow in one direction.
Some common layered designs are MVC (Model-View-Controller) and N-Tier (Multitier Architecture). The exact names and responsibilities differ across teams, but a typical setup might look like this:
Web (Framework, e.g. Express) -> Use Cases -> Repositories -> Models
A Use Case is an operation in your app, for example, “find latest blog posts” or “create comment.”
Use Cases typically need some form of storage (Repositories) to get work done. Most of the time this is a database like Postgres, but it could also be an API, MongoDB, a file, or any other storage.
Use Cases then manipulate Models such as BlogPost, User, and Comment, using the repositories for persistence. They are the orchestrators of the app and contain logic like “if the title of a new blog post is empty, it’s invalid,” or “when a post is published, notify its subscribers.”
There’s nothing inherently wrong with this design (we’ll get into the pros and cons later) but it does not satisfy Clean Architecture.
The dependency direction is the key difference
In a traditional Layered Architecture, the flow is top‑down: your business logic depends on the database. In Clean Architecture, this is inverted. Using Dependency Inversion [related, but different from Dependency Injection], the database is forced to depend on the Domain instead.
This inversion is what keeps your core logic from being tied to a specific database or framework.
Layered (Traditional): UI -> Business Logic -> Database (Logic depends on the DB)
Clean (Inverted): UI -> Business Logic <- Database (DB depends on the Logic)
The Domain (Use Cases and below) must not depend on external systems, only on code you control.
In terms of the Single Responsibility Principle, a database change shouldn’t force a change in the Use Case.
Making it “clean” with Dependency Inversion
Turns out the SRP is not the only SOLID principle being ignored here, it also breaks the Dependency Inversion Principle, which states that high-level code (Business Logic) should not depend on low-level code (Database).
Or in other words: Depend on abstractions (interfaces), not concretions (implementations).
With the DIP, we swap the direction of the arrows. Let’s see an example in TypeScript:
// A repository for Post models using an SQL database as storageexport class PostRepository { async create(post: Post) { await this.db.insert("posts", post); }}// A use case to create a new postexport class CreatePost { constructor(private postRepository: PostRepository) {} async execute(post: Post) { if (post.title === "") { // ... } // finally save the post await this.postRepository.create(post); }}
An Express route might instantiate CreatePost and call execute().
This works, but the Use Case depends directly on PostRepository. If the repository changes, the Use Case may need to change too.
Let’s use DI to solve this. First, we’ll replace the repository with an interface:
// A generic repository for Post modelsinterface IPostRepository { create(post: Post): Promise<void>;}
Now the Use Case depends on the interface:
export class CreatePost { constructor(private postRepository: IPostRepository) {} async execute(post: Post) { if (post.title === "") { // ... } await this.postRepository.create(post); }}
And the concrete implementation moves to the Adapter layer:
class PostRepository implements IPostRepository { async create(post: Post) { await this.db.insert("posts", post); }}
The new dependency flow becomes:
Web -> Adapter -> Use Case -> Port (Repository Interface) -> Model
The Adapter depends on the Use Case, not the other way around.
This means that when the adapter changes, say you switch from SQL to MongoDB, only the outer layers need to be updated.
With this setup, the Domain depends only on abstractions, not concrete implementations.
The “Import” Litmus Test
A quick way to tell whether your architecture is actually “Clean” is to look at the imports at the top of your files.
In a Layered Architecture, your CreatePost use case might contain an import like:
import { PostRepository } from '@/persistence/PostRepository'
This means the business logic is reaching outward into the database layer.
In Clean Architecture, this never happens. Your CreatePost use case imports nothing from infrastructure or persistence. It only imports from the Domain.
Instead, use cases depend on interfaces:
import { IPostRepository } from '@/domain/ports/PostRepository'
Now the imports stay entirely inside the Domain layer, which means the Domain no longer depends on the outside world. The dependency arrow has flipped: the database layer must depend on the Domain, not the other way around.
This is the simplest, most concrete way to see Dependency Inversion in action!
DI Container
Once you start depending on interfaces instead of concrete classes, you run into a practical question: who actually builds all these objects and wires them together? That’s where a Dependency Injection Container comes in.
A DI container is simply an object responsible for instantiating and assembling all the pieces of your application, and depending on your architecture, that can be a lot of pieces! It’s essentially a configuration file for your dependencies, and some languages and frameworks provide support for this out of the box.
A minimalistic container might look something like this:
// src/adapters/domain.tsexport const domain = { // Adapters postRepository(): IPostRepository { return new PostRepository(); }, // Use Cases createPost() { const posts = this.postRepository(); // The CreatePost use case expects an IPostRepository, // which PostRepository implements. return new CreatePost(posts); }}
From an Express route, you might use it like this:
app.post('/posts/create', async (req, res) => { const params = /* collect parameters */; await domain.createPost().execute(params); // handle response, errors, etc.});
From the outside world, you don’t need to worry about which ports exist, which adapters implement them, or how all the dependencies are wired together. You simply ask the domain for the object you need and use it. That’s it!
A Practical Approach
// LAYERED // CLEANsrc/ src/└── domain/ ├── domain/ ├── use-cases/ │ ├── use-cases/ │ └── create-post.ts │ │ └── create-post.ts └── repositories/ │ └── ports/ └── posts.ts <---(DB) │ └── posts.ts <---(INTERFACE) └── adapters/ └── repositories/ └── posts.ts <---(DB)
All of this leads to the real question: How do you balance Clean Architecture’s benefits with the realities of day‑to‑day development? As you’ve seen, following the strict version adds layers, files, and ceremony, so the real question becomes how much your app actually gains from that structure.
My first attempt at “doing Clean Architecture right” followed Hexagonal Architecture as strictly as possible. I had the adapters layer, the interfaces, the whole thing. The domain was perfectly isolated and depended only on itself.
But after using that setup in a few mid‑sized apps, I realized the interfaces required for proper Dependency Inversion were doing more harm than good.
In one of those projects, our only infrastructure dependency was the database, and our test suite already ran against an in‑memory SQLite instance. Since we were using the real repositories in tests, maintaining all the interfaces just to satisfy Dependency Inversion didn’t give us any practical benefit.
These projects were built by me and one or two other developers, and even as the apps grew, we never needed the level of ceremony that strict Clean Architecture demands. So we shifted to a more practical, “Clean‑Architecture‑inspired” Layered Architecture: we keep the layers and the one‑way dependency flow, but we only introduce interfaces when we actually need them, either for mocking in tests, because we expect to swap an implementation later, or because we genuinely have multiple implementations.
Interfaces and Indirection
This is where the downsides of strict Clean Architecture start to show up. Once you introduce interfaces everywhere, you also introduce indirection. Your editor’s “Go to Definition” stops taking you to the code that actually saves a post to the database. Instead, it jumps to an interface, just a list of method names, forcing you to dig through adapters to find the real implementation.
The friction grows as the number of layers grows. Consuming a third‑party API means defining an interface in domain/ports/my-api.ts and then implementing it in adapters/services/my-api.ts. If you add a web adapter instead of calling use cases directly, that’s yet another hop. And if you follow the Interface Segregation Principle strictly, your use case might depend on several tiny interfaces like InsertPost and FindLatestPosts, each with a single method, while the repository adapter implements all of them. The interface count quickly explodes.
In a large codebase, this creates real cognitive load. Navigation slows down, debugging becomes harder, and onboarding new developers becomes more painful.
Testing and the Solitary vs. Sociable Split
Interfaces shine in testing. You can mock them and test use cases in isolation. But you still need to test the actual adapters. That leaves two approaches:
- Solitary testing: Mock every dependency and test the use case in isolation. Database changes shouldn’t break the test.
- Sociable testing: Test the use case and repository together, often using a test database.
Solitary tests protect boundaries but miss integration issues. Sociable tests catch real‑world failures with less setup. For small and medium teams, the overhead of maintaining mocks for every interface often outweighs the benefits.
I also believe the less mocking you have in your tests, the better. Whenever possible, I prefer using the real thing. Of course, you don’t want to consume real APIs or send real emails during tests, so mocking is still necessary in those cases, but ideally only for external systems you truly can’t run locally.
Decoupling in Code vs. Decoupling in Reality
Clean Architecture promises that your domain doesn’t depend on your database. In code, that’s true: the use case depends on IPostRepository, not on SQL. But at runtime, the domain still depends on the database’s behavior. A change in how your SQL adapter handles transactions, defaults, or constraints can break domain logic even though the interface hasn’t changed.
Layered Architecture can still give you most of the same benefits. If you want to swap databases, you can create a new repository with the same methods as the old one. And if your tests use real repositories against a real database, they’ll quickly surface any issues.
Models, ORMs, and Practical Boundaries
Object-relational mappings (ORMs) are worth calling out because they’re extremely popular, and their conventions, especially the Active Record pattern, can blur architectural boundaries when you’re learning Clean or Hexagonal Architecture. Particularly if you come from MVC frameworks such as Rails or Laravel.
In Clean Architecture (and Hexagonal), your models are plain objects that you own. They contain only domain‑specific rules about themselves, for example, a blog post model knows it can’t be created without a title. They do not know how to persist themselves, query the database, or serialize over the network. Anything outside their own invariants belongs to another layer.
ORMs introduce a different model concept. An ORM model is both a domain entity and a database table. It can query, persist, and update itself. That’s convenient, but it forces a design decision: your domain objects now depend on database behavior and third‑party library code.
That convenience comes with architectural consequences; it breaks several SOLID design principles:
- Single Responsibility Principle — models mix domain rules with persistence.
- Open/Closed Principle — persistence changes force edits to the model.
- Liskov Substitution Principle — models inherit infrastructure behavior, so they can’t act as plain domain objects.
- Interface Segregation Principle — models expose a large, multi‑purpose API the domain doesn’t need.
- Dependency Inversion Principle — the domain depends on low‑level infrastructure details.
Clean Architecture doesn’t forbid ORMs, but it does require that ORM models live in the adapter layer, not the domain. Your domain models and ORM models become two separate things, and you translate between them. That works, but it adds another layer of mapping and complexity.
Layered Architecture gives you more freedom. You can return ORM models directly from your repositories, but doing so couples your business logic to the ORM, which is fine for many teams but reduces flexibility. Converting ORM models into your own domain models keeps the option to swap ORMs or databases later, at the cost of a bit more work.
This tradeoff is why many teams end up with a hybrid approach: Keep the domain clean enough to evolve but avoid unnecessary abstraction when it doesn’t provide real value.
A Practical Middle Ground
All of this led me to settle into a middle ground between Clean Architecture and Layered Architecture. It’s fundamentally Layered, but it borrows a few constraints from Hexagonal Architecture to keep the domain clean without drowning in interfaces.
Interfaces are introduced only when they provide real value. If a dependency might need mocking, swapping, or multiple implementations, it gets an interface. If not, the code stays simple and direct. This avoids the explosion of abstractions that strict Clean Architecture encourages while still preserving the option to decouple when it matters.
Testing follows the same philosophy. A sociable approach, using an in‑memory SQLite database and real repositories executing real SQL, keeps tests close to real behavior. It reduces boilerplate, avoids brittle mocks, and gives you confidence that your use cases actually work with the data layer. For most real‑world projects, this balance keeps the architecture clean enough to evolve while staying practical under real constraints.
Conclusion
Clean Architecture and Layered Architecture are both solid patterns, each with clear strengths and tradeoffs. Like everything in software, and in life, there’s no perfect solution. Every architecture optimizes for something and compromises on something else.
| Feature | Clean Architecture | Layered Architecture |
|---|---|---|
| Primary Goal | Independence, testability, long-term scalability. | Simplicity, development speed, clear organization. |
| Dependency Flow | Always Inward: Infrastructure depends on the domain | Top-Down: Domain depends on the infrastructure |
| Developer Experience | High Indirection: “Go to Definition” often lands on interfaces. | High Traceability: “Go to Definition” jumps straight to implementation. |
| Code Volume | More code: Adapters, DTOs, interfaces | Less code: Models and repositories used directly |
| Complexity | Higher; harder for new developers | Lower; easier to understand and onboard |
| Testing | Highly decoupled; easy to test logic without infrastructure | Testable, but often requires some infrastructure |
| Scalability | High; strict boundaries enforced by design | Scalable, but relies on team discipline |
| Flexibility | High; swapping DB/UI rarely touches the domain | Moderate; DB changes may require more work |
| Enforcement | Architecture enforces correctness | Team discipline enforces correctness |
| Best For | Large or long‑lived projects; shared domains (e.g., mobile + web) | Teams that value practicality and speed; any project size |
Shared Goals
Despite their differences, both Clean Architecture and Layered Architecture ultimately strive for the same goals:
- Separation of Concerns: Keeping UI, business logic, and persistence distinct.
- Maintainability: Making it easier to change one part without breaking others.
- Independence: Isolating core logic from frameworks and infrastructure.
- Testability: Enabling unit and integration tests without spaghetti dependencies.
- Directional Flow: Ensuring dependencies move in one direction to avoid ripple effects.
These shared goals are why both patterns have survived for decades and continue to be used in modern systems.
🐝 How Beezwax Can Help
If you’re facing architectural decisions, wrestling with legacy code, or planning a new system, this is exactly the kind of work we do at Beezwax. We help teams design, build, and refine software across the stack — web, mobile, desktop, data, APIs, MCPs, UI/UX, and everything in between. We help teams integrate AI into their workflows and products in a way that’s practical, safe, and aligned with real business needs.
Whether you need a second pair of eyes on your architecture, support with a tricky refactor, or a partner to build something new, we’d love to help.
The Real Point
Architecture is a tool, not a doctrine. The best structure is the one that helps your team move quickly without losing clarity. Some teams thrive with strict boundaries and heavy abstraction. Others move faster with a disciplined, lightweight layered approach. Many end up somewhere in the middle.
What about you? Does your team lean toward strict boundaries, practical layering, or something entirely different?
