2# My Journey From CRUD Developer to Building a Production-Oriented Go Backend
When CRUD Starts Requiring Boundaries
When I first started learning backend development, I was satisfied enough just to get an endpoint working.
- A request comes in.
- The handler processes it.
- The database stores the data.
- A response is returned.
- Done.
Technically, there was nothing wrong with that approach.
The problem arose when the application started to grow.
A single endpoint would begin doing too much. Validation became mixed with business logic. Database queries appeared in places that shouldn't even know about SQL. Database errors were passed directly through as HTTP responses. Parts of the application even became interdependent simply because they needed a small function from another section.
At that point, I began to realize one thing: "Code that works is not necessarily a system with a good design".
From "Getting It Up and Running" to "Who Is Responsible?"
The handler receives the request, validates the input, executes the query, and then returns the response. For simple CRUD operations, this approach feels very efficient.
For example:
The problem isn't with those few lines of code. The problem lies in the responsibility. The handler now knows:
- how HTTP works,
- how requests are parsed,
- how data is validated,
- how business rules are applied,
- how SQL is executed,
- how database errors are handled,
- and how HTTP responses are constructed.
The more features added, the greater the likelihood that the handler turns into a dumping ground for all the logic. I then began to separate those responsibilities.
Repository: Separating Persistence from Business Logic
The first step is to introduce the repository. It is not because the repository is a "mandatory" pattern, but rather because I need a boundary between the application and the persistence layer.
The architecture is starting to change:1# Picture
The repository is responsible for persistence. The use case is responsible for application or domain rules. The handler is responsible for HTTP. At first glance, it looks like simply adding an extra layer. But in reality, what changes is the direction of dependencies.
A use case does not need to know whether the data is ultimately stored using MySQL, PostgreSQL, or another implementation. It only needs to know the capabilities it requires. For example:
It doesn't matter how FindByID works. It only needs the contract.
I Am Starting to Realize That the Interface Is Not the Goal
This is something that took me quite a while to fully grasp. At first, itβs easy to fall into this pattern:
"Use an interface to ensure good architecture."
Yet, the interface itself isn't the goal. If an interface is created merely to wrap a single function without establishing clear boundaries, that abstraction actually just adds noise. The more important question is:
"Who needs this abstraction?"
If a use case requires the ability to retrieve a user, the contract should reflect the needs of that specific use case. It shouldn't describe the database's entire set of capabilities. For instance, I don't need:
simply because my repository database possesses all those capabilities. An interface should ideally serve merely as a contract for the dependencies that are actually required. From this point on, I began to view interfaces not merely as a feature of the Go language, but as a tool for establishing boundaries.
A UseCase Is Not a Place for SQL to Hide
Once the repository is separated, the next question arises: What exactly is the responsibility of a use case? I began distinguishing between two types of logic:
- Persistence logic
- INSERT
- SELECT
- UPDATE
- DELETE
- JOIN
- Transactions
Business/application logic Is the user allowed to perform this operation? Are the credentials valid? Does the user's status permit the operation? Is the data allowed to be created? What should happen if certain conditions are met?
The repository handles persistence. The use case handles rules related to the operation.
For example:
Register βββ validate input βββ check email availability βββ create credential βββ create profile
The use case knows what needs to be done. The repository knows how the data is stored. This distinction appears simple, but it becomes crucial as the system grows.
A More Difficult Problem Arises
Separating handlers, use cases, and repositories solves one class of problems. But then, I encountered another issue.
Imagine a registration process:
Create Credential β Create Profile
Both are part of a single business operation.
What happens if:
- Create Credential β
- Create Profile β
If each repository commits independently, the system is left with incomplete data.
- The credential exists.
- The profile does not.
Yet, from the application's perspective, the registration has failed.
That is when I began to realize that: Boundaries are necessary not just for code, but also for the lifecycle of an operation. And eventually, I came across the concepts of transaction boundaries and the Unit of Work pattern. But before getting to that, there is a more important concept to understand.
Dependencies Must Have a Direction
The more abstractions you create, the easier it is for dependencies to become messy. I have started ensuring that dependencies flow in a clear direction:
Transport β Application β Domain / Usecase β Repository Contract β Infrastructure
Infrastructure is allowed to know about implementations. Transport is allowed to know about HTTP. However, the domain should not suddenly have knowledge of:
- net/http
- sql.DB
- or specific storage details.
Why?
Because when the domain knows about the infrastructure, that boundary has effectively been breached.
For instance, if the business logic contains:
then that logic is now coupled to HTTP.
If, down the line, the same process needs to be invoked from:
- an HTTP API,
- a worker,
- a CLI,
- or a scheduled job,
that logic is no longer easily reusable.
Conversely:
regardless of who calls it. HTTP is just one adapter.
Layering: More Layers Doesn't Mean Better
This is also an important lesson.
Creating:
- Handlers
- Controllers
- Services
- Managers
- Helpers
- Repositories
- Providers
- Factories
- Adapters
- Facades
- ...
does not automatically make an application better.
If every layer simply passes a function along:
A β B β C β D β E
without having distinct responsibilities, we are merely creating indirection.
The goal of abstraction is not to increase the number of files.
The goal is to make: responsibilities, dependencies, and lifecycles clear.
That is why I have become more cautious when adding an abstraction.
I no longer ask:
"What pattern can I use?"
I start asking:
"What problem am I solving?"
From CRUD to Systems Thinking
The biggest change isn't actually in the folder structure.
The biggest change lies in how I view the backend.
I used to see it like this:
Endpoint β Database Query β Response
Now, Iβve started seeing it like this:
Request β Transport Boundary β Application Operation β Domain Rules β Persistence Boundary β Infrastructure
And as operations become more complex, new questions arise:
- What about transaction boundaries?
- How are errors translated?
- How are requests traced?
- How is concurrency handled?
- How are resources cleaned up?
- How does the system handle partial failures?
- How is the lifecycle of each resource managed?
These questions ultimately led me to the next topic.