introduction
golang emphasizes simplicity and often, after starting a new project, we tend to use layered architecture because it’s simple and easily understandable. it immediately clicks and we know where to put database logic, business logic, and apis. we understand why it works and its benefits.
but after some time, we find ourselves jumping around every damned layer, fixing dtos and converters that are centralized across the whole service (did we really want that?). implementing a single feature with +100 tabs opened in the editor, praying that the next tab-switch will bring us where we wanted.
i fell into that trap too many times, but i will never ever do it again.
materialized example
let’s start with basic service requirements;
the overall idea is a simple order management system.
requirements:
- rest api for web clients
- grpc api for integration clients
- database for storing orders, clients, etc.
- external service for processing payments
- graceful shutdown and health check endpoints
layered approach
any junior dev can understand what is happening here. a layered structure has several pros: clear separation of concerns, ease of onboarding, and familiarity. but there is a cruel temptation to implement it roughly like this.
.
├── internal
│ ├── api
│ │ ├── grpc
│ │ └── http
│ ├── repositories
│ │ ├── client
│ │ └── order
│ └── services
│ ├── customer
│ ├── order
│ └── payment
where it breaks
that structure quickly leads to enormous amounts of hopping between dozens of folders and files just to implement a single feature. what should be a simple change turns into a cross-cutting modification across the entire codebase. for example, to implement order cancellation in this structure, you actually need to touch and make changes across a minimum of 5-7 or more files:
- http handler
- grpc handler
- service layer
- repository
and dont forget: dtos and converters between each layer!
real story
i’ve recently encountered the same exact problem. i’ve been assigned to develop a service from scratch for my company, and at the moment when my colleagues and i had to choose a project structure, we agreed to keep things simple and avoid over-engineering at the start. a layered structure seemed like the obvious choice, simple and easy to understand for any newcomers.
one month after, the codebase had grown a lot and we had something like 100+ files and 50 dirs. there were two devs, including myself, but even then, it was a nightmare to move forward at the same speed as weeks ago because of never-ending hops between files to implement or refactor a feature.
breaking the habit
i admit every project is different, and conventions vary between companies. i’ve researched many resources, like conferences and articles about the same topic, and nothing was fully covering all of my internal expectations about how i want to see the structure. i don’t know why, but it was genuinely hard to move on after the layered structure, maybe because you don’t often work with anything else.
so, okay, i understand, breaking the habit may take a while
hexagonal architecture point
i’m also wishing to emphasize simplicity, so i quickly ruled out hexagonal architecture with its adapters, ports, and other abstractions. sorry, folks.
v-slice
so, i’ve decided to try a vertical slice structure to remove all this mental pressure. i cannot fully name this structure "vertical" as it is stated publicly, because i’ve introduced personal modifications (or maybe just do not exactly understand it as a whole), but what came out has very much improved our performance and lowered pressure while developing software. it wasn’t the first try; there were 3-4 iterations before i finally settled on one.
first iteration
.
└── internal
├── app
│ ├── customer
│ │ ├── repository
│ │ └── service
│ ├── order
│ │ ├── repository
│ │ └── service
│ └── shared
│ ├── application
│ ├── enums
│ └── models
└── pkg
├── clients
└── database
you can quickly realize what is happening here.
expectations:
- yes, you divide by features like "client" or "order" folders.
- yes, you now stay in one place for one feature.
reality:
- no, you just created a mini-layered structure inside every feature package.
- no, you still have a so-called "shared" package which will grow when you notice cross-feature logic or models.
- no, i don’t even know where to put api handlers here, don’t even ask me.
first of all, i think it got even worse than just layered architecture—now there are two architecture patterns at once; good luck improving developer productivity with that
second iteration
after a few moments later:
.
└── internal
├── app
│ └── app.go
├── customer
│ ├── repository.go
│ └── service.go
├── clients
│ └── payment
├── config
├── order
│ ├── repository.go
│ └── service.go
├── orderstore
│ └── store.go
└── platform
├── application
├── database
└── transport
├── grpc
│ └── order_handler.go
└── http
├── customer_handler.go
└── order_handler.go
- okay, this looks better, feels better, but what is that orderstore package doing here?
- ah, your customer feature needs access to orders for something? no problem, you have it.
- oh, what if the order package needs to know about customers? let’s expose customerstore—what could be wrong?
- oops, here we go again to shared repositories like in the layered thingy.
third iteration
- wait, have we really been organizing repositories by entities this whole time?
- are we saying that we can design repositories around use cases instead of generic crud methods — and call them stores?
.
└── internal
├── app
├── customer
│ ├── customer_record.go
│ ├── customer.go
│ ├── info_service.go
│ ├── order_record.go
│ ├── order.go
│ └── store.go
├── clients
│ └── payment
├── order
│ ├── canceling_service.go
│ ├── order.go
│ ├── ordering_service.go
│ ├── record.go
│ └── store.go
└── platform
├── application
├── database
└── transport
├── grpc
└── http
conclusion
in the third iteration, i had my 'aha!' moment, shifting from entity-centric repositories based on single entities and crud ops to use-case-driven design of stores. implementing a specific data retrieval function only for your feature case feels a bit clunky at first, but when you realize the freedom around it all, you quickly forget it could be any other way.
in a layered approach, getting a count of a customer's orders means you would need to bring in an order repository to call something like GetOrderCustomer (with a customer filter as an argument). but actually, nothing stops you from implementing GetCustomerOrderCount(customerID) inside a customer_store.go; no other feature would ever need to know about that, just because it has its own store for its own purposes.
exactly this mind shift helped me free myself from the dilemma of what i should do with the data access layer and live happy.
p.s
this is my first post, ask me any questions in the comments section
also, tell me if you want me to create a repo with final structure using example service
Top comments (0)