The Golang4U Architecture: A Service-Oriented Masterclass
Preface: Why Read This?
If you are just starting with Go, you might write everything in main.go. It works for a “Hello World,” but it fails for a production system.
If you are an expert, you might argue about “Clean Architecture” vs “Hexagonal” vs “Onion.”
Golang4U adopts a pragmatic, production-ready Service-Oriented Architecture (SOA) structure. It is designed to be:
- Testable: Every layer can be mocked.
- Scalable: New features (like a “Video” post type) don’t break old ones.
- Readable: A new developer knows exactly where to put code.
This guide is not a summary. It is a Masterclass. We will read the code together, line by line, layer by layer. By the end, you will understand not just how it works, but why it was built this way.
Chapter 1: The Blueprint (Folder Structure)
Go is infamous for not having a standard structure. However, the community has coalesced around the Standard Go Project Layout. We define our project by What it is, not What it does.
1.1 The High-Level Map
graph TD
Root[golang4u_server]
Root --> Cmd[cmd/]
Root --> Internal[internal/]
Root --> Pkg[pkg/]
Cmd --> ServerMain[server/main.go]
Internal --> Handler[handler/]
Internal --> Service[service/]
Internal --> Repository[repository/]
Internal --> Model[model/]
Internal --> Config[config/]
Internal --> Security[security/]
1.2 cmd/: The Entry Point
This folder is the “Main Entrance.” It contains the main package.
- Rule: Minimal logic. This file should just parse flags, read config, and call
server.Run(). - Why?: By keeping
mainsmall, we allow others to import our application code (ininternal) without running the server.
1.3 internal/: The Private Core
In Go 1.4+, directories named internal are special. The Go compiler forbids external projects from importing packages inside internal.
- Why?: This enforces API boundaries. We are free to break things inside
internalwithout worrying about breaking 3rd-party users of our library. - The Layers:
- Handler: Speaking HTTP (Parsing JSON, Status Codes).
- Service: Business Logic (Validation, Encryption, Emailing).
- Repository: Data Access (SQL, Mongo, Redis).
1.4 pkg/: The Public Library
Code here is safe for anyone to use.
- Example:
pkg/utilsmight contain a helper to format dates. - Rule: No business logic here. Just generic tools.
Chapter 2: Dependency Injection (The Wiring)
The most critical pattern in Golang4U is Dependency Injection (DI).
Many beginners use Global Variables for database connections (var DB *sql.DB).
This is the root of all evil.
- It makes parallel testing impossible (tests overwrite each other’s DB).
- It hides dependencies (you don’t know function X uses the DB).
2.1 The Constructor Pattern
We use “Constructor Functions” (factories) to create our structs.
In internal/handler/handler.go:
| |
Key Takeaway: The Handler asks for what it needs. It doesn’t build it.
2.2 The Composition Root (server.go)
If everyone asks for dependencies, who creates them?
The Composition Root. In our case, SetupServer in internal/server/server.go.
| |
This creates a clear dependency graph:
Router -> Handler -> Service -> Repo -> DB.
It is an Acyclic Graph. (No circles).
Chapter 3: The Presentation Layer (Handler)
The Handler layer is the Diplomat. It speaks the language of the Web (HTTP). Its job is to translate JSON into Go Structs, call the Service, and translate results back to JSON. It must NEVER contain business logic.
Detailed Walkthrough: HandleFormSubmission
Let’s look at a complex handler in internal/handler/handler.go.
| |
The Sub-Handlers pattern
Notice handleNews. The main handler is getting too big, so we split it.
| |
Why extract the file bytes here?
If we passed c *echo.Context to the Service, the Service would depend on echo (HTTP). That violates our layering. By extracting []byte, the Service receives pure data. It can be tested without a web server.
Chapter 4: The Business Layer (Service)
The Service layer is the Brain. It contains the rules of the application. “Users must be 18+.” “Passwords must be encrypted.” “Emails must be unique.”
Case Study: UserService.CreateUser
In internal/service/user_service.go:
| |
Notice what is missing:
- No
c.JSON. The service returnserrororstruct. It doesn’t know about JSON. - No
http.StatusForbidden. It returns domain errors (ErrDuplicateUser).
Chapter 5: The Data Layer (Repository)
The Repository layer is the Librarian. It knows how to talk to the database.
We define it using an Interface in internal/database/database.go.
| |
Why usage Interface? (The Mocking Power)
Imagine writing a test for Service.CreateUser.
If Service depended on *mongo.Client, you would need to run a MongoDB Docker container just to run go test. That’s slow.
With an interface, we can do this:
| |
Now our unit tests run in milliseconds.
The “Store” Wrapper Pattern
Golang4U uses a NoSQL database (MongoDB implied by key-value semantics in database.go).
But we don’t just dump the User struct. We wrap it.
| |
This “Metadata Wrapper” allows us to add system-level flags (like Disable) without polluting the User object that might be returned to the frontend Main API.
Chapter 6: Cross-Cutting Concerns
Some logic touches every layer.
6.1 Configuration (config/)
We load config once in main, and pass it down.
Handler needs config.ClamavURL.
Service needs config.GmailToken.
This is passed via the Constructor, ensuring every component has explicitly declared its configuration needs.
6.2 Security (security/ & ipfilter/)
Security is often implemented as Middleware (wrapping the Handler).
- IPFilter: Checks IP before Handler logic.
- RateLimit: Checking frequency.
- PASETO: Checks Auth Tokens.
But deep security (Encryption) happens in Service (as seen in CreateUser).
Chapter 7: Error Handling Strategy
Go errors are values. In golang4u, we propagate them up.
- Repository: Returns
mongo.ErrNoDocuments. - Service: Wraps it:
fmt.Errorf("user not found: %w", err). - Handler: Unwraps it (using
errors.Is) and decides the HTTP status.- If
user not found->404 Not Found. - If
db connection failed->500 Internal Error.
- If
This keeps the “HTTPness” in the handler. The Service shouldn’t decide if an error is a 400 or a 404; it just says “It failed because X”.
Chapter 8: The Request Lifecycle, End to End
The three chapters above are the static picture: who depends on whom. This is the dynamic one — the same request, traced through the layers, and exactly where each one does its one job.
sequenceDiagram
participant C as Client
participant M as Middleware (IP / rate / PASETO)
participant H as Handler
participant S as Service
participant R as Repository
participant DB[(DB)]
C-->>M: POST /forms/news
M-->>M: IP in blocklist? over bucket? token valid?
M-->>H: c with "user" set, body bound-ready
H-->>H: bind + validate (struct tags)
H-->>S: CreateNews(ctx, NewsFormData)
S-->>S: decrypt ECIES field, compute blind index
S-->>R: Save(user, news)
R-->>DB: insert
DB--->>R: ok
R--->>S: nil
S--->>H: nil
H--->>C: 200 + JSON
Note over M,DB: a layer re-checking what an earlier layer proved = paid twice
The one rule that makes the whole diagram true: a layer reads only what the layer above it handed down, and it never reaches sideways. The handler owns the echo.Context; the service receives a ctx (the context.Context) plus plain data; the repository owns the DB handle. No layer imports the layer below it through the one above. That single line is why the folder tree in Chapter 1 and the DI wiring in Chapter 2 are the same decision, seen from two angles.
| Layer | Owns | Receives | Never touches |
|---|---|---|---|
| Handler | the echo.Context, binding, validation | decoded []byte, bound struct | business rules, the DB |
| Service | business rules, encryption, the blind index | plain data structs, context.Context | echo, HTTP status codes |
| Repository | the DB handle, key wrapping | domain structs | the web, validation |
| Middleware | request shape (IP, rate, token) | the raw request | business logic |
The table is the contract. The most common architectural bug in a Go codebase is not “it doesn’t compile” — it’s a service that does c.JSON or a handler that opens a DB handle. Both are layer leakage, and both show up first as a test you can’t write in isolation.
Note: the dependency direction is the invariant; the folder names are the decoration. You can rename internal/service to internal/domain and nothing breaks. You can’t have the Service import the Handler. If you find yourself wanting to move logic up a layer to “fix” a handler, the layer boundary is the real defect, and the move will break the mock you have in the test.
Warning: a “thin” handler is a spec, not a guarantee. The moment a handler starts computing something (a total, a price, an if user.Role == "admin") it has crossed from “protocol translation” into “business logic,” and your unit test for it has quietly become an integration test in disguise. The smell is not the code; it’s the test needing a DB.
Conclusion
The Golang4U architecture is an exercise in Discipline.
It is tempting to just write a SQL query inside a Handler.
It is tempting to just use a global var DB.
But by adhering to this Service-Oriented-Architecture, we gain:
- Isolation: Bugs in the DB layer don’t break the HTTP layer logic.
- Clarity: New developers know exactly where to put new code.
- Confidence: We can test every piece in isolation.
This is the standard for professional Go engineering.
Takeaways
internal/enforces the boundary the compiler can’t see. It is the only thing standing between yourhandlerand a third party importing it — and between yourserviceandecho. The folder is the API contract; the import path is the proof.- One constructor per struct, one composition root.
SetupServeris the only place that knows all the dependencies exist at once. Every other function asks for its deps. That is what makes the test able to swap the DB for a map without touching a line of production code. - Handler = protocol, Service = rules, Repo = storage. The instant a service calls
c.JSONor a handler opens a DB handle, the boundary is a lie, and the first test that needs a real database is the symptom. The layer you can’t mock is the layer that’s leaking. - The store wrapper is metadata, not data.
Store{User, Disable, Lifetime}lets the system carry system-level state (ban flag, TTL) without polluting the user-facing object the API returns. Adding a flag is a field, not a schema migration. - Errors are values with meaning, and the meaning is decided at exactly one layer. The repo says “no document,” the service wraps it, the handler maps it to a status. The moment two layers both decide the HTTP code, the 404/500 boundary becomes a coin flip.
- Renaming is free; re-pointing a dependency is the event. Refactor the names and the build still passes. The only change that ever breaks a consumer is the one that moves a dependency across a layer. Optimize for that not happening.