I kept agonizing over "modular monolith or layered" — but it was never a choice between the two
I have just joined a new project, and catching up on the domain is eating all of my time, energy, and spare brain cycles — so my side project has come to a complete stop.
In the middle of that, I was reading Fundamentals of Software Architecture and started thinking about the anonymous chat service I build on the side.
The design doc says “we adopt a modular monolith.” But open the actual code and there are controllers, services, and repositories. It looks like layered architecture by any measure.
Aren’t these two mixed together? How are they supposed to relate?
The short version: they were not mixed, they were nested. And the two were never the kind of thing you compare and choose between in the first place. Obvious in hindsight, maybe, but I spent a while not seeing it, so here is the write-up.
Here is what this post covers:
- What I was stuck on
- Draw it out and only the direction of the cut changes
- These two answer different questions
- What my code actually looked like
- Why the outer axis is “feature”
- Honestly, the inner boundaries aren’t enforced by the language
- “We chose layered, so we don’t split the outside into modules” doesn’t follow
- References
What I was stuck on
Read enough architecture articles and you keep running into these two layouts.
A. Split directories by layer
src/
├── controllers/
│ ├── chat.go
│ ├── matching.go
│ └── topic.go
├── services/
│ ├── chat.go
│ ├── matching.go
│ └── topic.go
└── repositories/
├── chat.go
├── matching.go
└── topic.go
B. Split directories by feature
src/
├── chat/
│ ├── controller.go
│ ├── service.go
│ └── repository.go
├── matching/
│ ├── controller.go
│ ├── service.go
│ └── repository.go
└── topic/
├── controller.go
├── service.go
└── repository.go
I called A “layered” and B “modular monolith,” and thought of them as something you choose between. That was the mistake.
Look closely: A and B contain exactly the same nine files. The only difference is how they are arranged. B still has controllers, services, and repositories. B is not “layered architecture, abandoned.”
Draw it out and only the direction of the cut changes
Lay the code out as nine cells. Layers run vertically, features horizontally.
chat matching topic
Controller 1 2 3
Service 4 5 6
Repository 7 8 9
The whole question was how to bundle these nine.
Layered cuts horizontally.
chat matching topic
==========================================
Controller 1 2 3
==========================================
Service 4 5 6
==========================================
Repository 7 8 9
==========================================
Three features’ worth of controllers live together inside controllers/. The walls sit between layers, and there is no wall between one feature and another. From the chat service, the matching service is right next door, in plain sight.
A modular monolith cuts vertically.
chat || matching || topic
|| ||
Controller 1 || 2 || 3
Service 4 || 5 || 6
Repository 7 || 8 || 9
|| ||
Controller, service, and repository all go inside chat/. The walls sit between features, and there is no wall between layers.
The important part: not one of the nine cells moved. All that moved is the direction of the walls. Which is why “pick one of the two” was never a coherent framing.
And the walls don’t have to be of one kind. The actual code looked like this.
chat || matching || topic
|| ||
Controller 1 || 2 || 3
------------||--------------||----------
Service 4 || 5 || 6
------------||--------------||----------
Repository 7 || 8 || 9
|| ||
|| = wall separating features (modular monolith)
-- = partition separating layers (layered)
Put up thick vertical walls, then divide the inside with horizontal partitions. A two-level structure. That difference in “thickness” between the walls and the partitions comes back later.
These two answer different questions
An apartment building makes it land a little better.
A modular monolith is about how many units you divide one building into, and where you put the walls.
Three units or five. And crucially, there is a wall between one unit and the next. Your neighbor cannot wander in and open your fridge. Whatever happens inside one unit has no effect on the others. Creating that “can’t reach in from outside” state is the whole point.
Layered is about how you divide the inside of one unit into an entryway, a living room, and a bathroom.
Life is awkward if the place you take your shoes off, the place you cook, and the place you sleep aren’t separated. But that is an internal matter for that one unit, and it has nothing to do with the neighbors.
Which is where it clicks.
“This building is divided into three units” and “each unit is a one-bedroom” can be decided at the same time.
“Three units, or a one-bedroom?” is not a valid question. The granularities are different. What I had been agonizing over was exactly that invalid question.
Incidentally, the building still stands even if the floor plans differ from unit to unit. But if every unit has the same plan, neither the residents nor anyone touring the place gets lost. That connects to something later on.
Summarized:
| Modular monolith | Layered | |
|---|---|---|
| What does it decide? | What units the whole system is divided into | How responsibilities are split inside one component |
| Granularity | The whole system | Inside a single module |
| Motivation | Keep features from becoming entangled | Keep input, logic, and persistence from mixing |
For what it’s worth, microservices are written layered inside each service too. Nobody says “microservices or layered.” Same thing here.
What my code actually looked like
Here is how mine was structured (it’s Go, but this is about structure, so the language doesn’t matter).
internal/
├── chat/ ← module boundary
│ ├── http.go controller
│ ├── chat.go domain / service
│ ├── repository.go interface definition
│ ├── postgres_repository.go its implementation
│ ├── store.go interface definition
│ └── redis_store.go its implementation
├── matching/ ← same internal structure
├── topic/ ← same internal structure
├── report/
└── moderation/
The outer split is by feature, the inner split is by layer. Cleanly nested. The reason I was confused is that the design doc said “modular monolith” and nothing else — not a single word about the inside.
For reference, genuinely “mixed” means something like this.
internal/
├── chat/ ← directory split by feature
├── matching/
└── services/ ← directory split by layer, living at the same level
That really is bad, because every time you add code you have to decide which side it goes on. Directories built on different criteria sitting at the same level is all it takes to break down.
Why the outer axis is “feature”
So why did I pick feature for the outer axis? It wasn’t in the design doc, so here is the reasoning, written out after the fact.
1. It is the unit you’d eventually extract
This was the big one. It’s a side project, so microservices aren’t the plan — but if, say, moderation (detecting inappropriate posts) turned out to be the one thing under heavy load, I might want to pull just that piece out into its own service.
Split by layer, the moderation code is scattered across controllers/, services/, and repositories/. Extracting it means digging through all three directories to collect the pieces. Split by feature, you take the moderation/ folder and you’re done.
Split along the lines you’d eventually want to split. That’s the whole argument.
2. The language’s access control actually applies
This part gets a bit Go-specific, so a note on it.
In Go, one directory is one package, and access control works at package granularity. An identifier starting with a lowercase letter is visible only within the same package; capitalized ones are visible from outside. It’s close to Java’s package-private or Rust’s modules.
So if you split by feature, the internals of the report package are simply not visible from the chat package. In terms of the earlier diagram, the || walls are actually standing there. The compiler enforces the boundary.
Split by layer and every feature’s service lives together in one services package, which means the boundary between features is zero at the language level. The chat service can reach into report’s internals as much as it likes. It’s three households sharing a studio apartment with no walls.
In languages with a strong DI-container culture like Java or C#, splitting by layer has its own payoff — but in Go this reason puts it at a serious disadvantage. You could say the choice was really “which axis do you spend the language-enforced boundary on?”
3. There was no “shared infrastructure layer” to begin with
I only noticed once implementation was underway: persistence looked completely different from module to module.
- Chat … PostgreSQL + Redis Pub/Sub + managing WebSocket connections
- Matching … Redis sorted sets + PostgreSQL
- Topic list … PostgreSQL + in-process cache
- Moderation … no persistence at all (one file, pure logic)
There was no shared “repository layer” in the first place. Had I created a repositories/ directory, it would have held wildly different things, one of them empty — a box with nothing but a name.
On top of that, long-lived objects that don’t begin and end with a request and response, like WebSocket connection management, don’t fit in the controller, service, or repository drawer at all. If you try to split a service with real-time communication by layer, that’s probably where it breaks down first.
Honestly, the inner boundaries aren’t enforced by the language
This is where I’d like to wrap things up neatly, but there’s one inconvenient fact.
The partitions I drew as -- earlier don’t physically exist.
chat.go (logic) and postgres_repository.go (where the SQL lives) sit in the same directory, which in Go means the same package. Write out the declarations at the top of each file and it looks like this.
internal/
├── chat/ ← there is a wall here
│ ├── http.go package chat ┐
│ ├── chat.go package chat │ as far as Go is
│ ├── repository.go package chat │ concerned, these four
│ └── postgres_repository.go package chat ┘ are "one lump"
│
└── report/ ← and a wall here
├── http.go package report
└── report.go package report
Separate files or not, everything declaring package chat is the same package. Within a package, unexported lowercase functions and variables are freely reachable from each other. From chat.go’s point of view, postgres_repository.go isn’t “the file next door,” it’s part of itself.
Which means, if you felt like it, you could write this.
// internal/chat/chat.go
package chat
type Service struct {
repo Repository
pool *pgxpool.Pool // ← add one field
}
func (s *Service) End(ctx context.Context, id string) error {
// and write SQL directly in the logic file, bypassing repo
_, err := s.pool.Exec(ctx,
"UPDATE conversations SET ended_at = now() WHERE id = $1", id)
return err
}
It skips the repository layer, and it compiles. If nobody catches it in review, it gets merged as is.
Direction doesn’t matter either. You can just as easily call a Service method from postgres_repository.go — the forbidden “lower layer calls upper layer” move. With a package per layer that would be a circular import and a compile error; within one package, that safety net doesn’t exist.
A violation across modules, on the other hand, looks like this.
// internal/chat/chat.go
package chat
import "github.com/yosuke318/anontopic/internal/report"
func (s *Service) End(ctx context.Context, id string) error {
req := report.submitRequest{} // ← compile error
...
}
submitRequest starts with a lowercase letter, so from outside the report package its very existence is invisible. This one fails the build, so it never makes it to a commit.
(To be precise, exported types can be imported from other modules, so module boundaries aren’t fully automatic either. Still, having the option of “keep it unexported and the compiler will stop you” makes it far better off than the inside.)
Summarized:
| Boundary | Who enforces it | If you break it |
|---|---|---|
| Between modules (chat ↔ report) | The compiler | The build fails |
| Layers within a module | Discipline and review only | It just works |
The outer boundary is enforced by the language for free; the inner one only people can enforce. In apartment terms, the wall between units is poured concrete, while the line between the entryway and the living room is tape on the floor.
And this turned out to be the root of my confusion in the first place. Our CONTRIBUTING.md only documented the rules between modules (the horizontal ones), with not a single line about layer rules (the vertical ones).
- “Don’t import another module’s DB models directly” ← documented
- “Don’t touch the DB directly from a controller” ← not documented
The reason every module ended up with the same internal structure despite that is simply that I kept following the first one I wrote. I said earlier that identical floor plans keep residents from getting lost — but right now nothing anywhere justifies keeping those plans identical. There is nothing for a new contributor, or for me in six months, to refer to.
The boundaries the language won’t enforce are exactly the ones worth writing down. I had it backwards.
“We chose layered, so we don’t split the outside into modules” doesn’t follow
Finally, the thing I most wanted to get across in this post.
When you pick an architecture style, do you assume that the moment you decide “we’re going layered,” it automatically follows that directories get cut into controllers/, services/, and repositories/? I did. Which is why it looked like a choice between “layered or modular monolith.”
In reality you can split into modules and make the inside of each one layered.
- Outside: split into modules by feature (modular monolith)
- Inside: make each module layered
That keeps layered architecture’s benefit — not mixing input, logic, and persistence — while also getting boundaries between features. You don’t have to give up either one.
As for order, deciding the outside first is the more recoverable choice. The inner layer structure can be fixed later, module by module, but changing the outer axis later means relocating every file. And in practice, even if internal structures differ between modules, the system holds together as long as the outside is split.
Not “layered or modular monolith,” but “what axis do I cut the outside on, and how do I divide the inside of that?” Think in that order and the options open up considerably.
Taking another look at which architecture style the service you build or operate actually uses will deepen your understanding of it a level. Worth a check, if you’re inclined.
References
- Fundamentals of Software Architecture: An Engineering Approach (Mark Richards, Neal Ford) — O’Reilly