The Golang4U Architecture: A Service-Oriented Masterclass

The Golang4U Architecture: A Service-Oriented Masterclass cover

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:

  1. Testable: Every layer can be mocked.
  2. Scalable: New features (like a “Video” post type) don’t break old ones.
  3. 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.

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 main small, we allow others to import our application code (in internal) without running the server.

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 internal without 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).

Code here is safe for anyone to use.

  • Example: pkg/utils might 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).

We use “Constructor Functions” (factories) to create our structs.

In internal/handler/handler.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Handler struct {
    DB          database.DBStore           // Interface!
    data        *ttlcache.Cache[string, any]
    PostService *service.PostService
    UserService *service.UserService
}

// The "Constructor"
func NewHandler(db database.DBStore, ps *service.PostService, ...) *Handler {
    return &Handler{
        DB:          db,
        PostService: ps,
        // ...
    }
}

Key Takeaway: The Handler asks for what it needs. It doesn’t build it.

If everyone asks for dependencies, who creates them? The Composition Root. In our case, SetupServer in internal/server/server.go.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func SetupServer(db database.DBStore, config *config.Config) *Server {
    // 1. Create Services (injecting DB)
    userService := service.NewUserService(db, ...)
    postService := service.NewPostService(db, ...)

    // 2. Create Handlers (injecting Services)
    h := handler.NewHandler(config, db, userSvc, postSvc, ...)

    // 3. Create Router (injecting Handlers)
    e := echo.New()
    // ... wiring routes ...
    
    return &Server{e: e, h: h}
}

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.

Let’s look at a complex handler in internal/handler/handler.go.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (h *Handler) HandleFormSubmission(c echo.Context) error {
    // 1. Protocol Decoding
    formType := c.Param("formType") // "news", "jobs", etc.
    
    // 2. Security Check (Virus Scan)
    // The handler knows about 'filescan', but not 'how' to scan.
    if err := h.fs.ScanData(c, nil); err != nil {
        return err // 403 Forbidden or 500 Internal Error
    }

    // 3. User Context (Authentication)
    // We retrieve the authenticated user from the request context/token.
    userStore, err := GetAvatar(c, h.TTLDB, h.DB)
    if err != nil { return err }

    // 4. State Validation (Handler Logic)
    // "Is the user banned? Is the user verified?"
    // This is borderline business logic, but it's acceptable here 
    // as it gates access to the service call.
    if userStore.Disable {
        return c.JSON(http.StatusForbidden, "Disabled")
    }

    // 5. Routing Logic
    switch formType {
    case "news":
        return h.handleNews(c, ctx)
    case "learn":
        return h.handleLearn(c, ctx)
    // ...
    }
}

Notice handleNews. The main handler is getting too big, so we split it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (h *Handler) handleNews(c echo.Context, ctx context.Context) error {
    // A. Bind (JSON -> Struct)
    data := new(formdata.NewsFormData)
    if err := c.Bind(data); err != nil { return err }
    
    // B. Validate (Struct Tags)
    if err := c.Validate(data); err != nil { return err }
    
    // C. File Handling
    // The handler knows about Multipart Forms. The Service does not.
    // So the handler extracts the []byte here.
    data.NewsCover, _ = readFile(c, "news-cover")

    // D. Call Service
    return h.PostService.CreateNews(ctx, data)
}

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.”

In internal/service/user_service.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (s *UserService) CreateUser(newUser *user.User) error {
    // Logic 1: Decryption (Privacy)
    // The raw request has encrypted fields. We unlock them here.
    plainEmail, _ := security.DecryptECIES(s.privKey, newUser.EncrypedEmail)

    // Logic 2: Blind Indexing (Uniqueness)
    // Check if email exists without storing plain email.
    hash := sha256.Sum256(plainEmail)
    if s.repo.Exists(hash) {
        return Error("Already Registered")
    }

    // Logic 3: Roles
    if s.repo.Count() == 0 {
         newUser.Role = "admin" // First user is King
    }

    // Logic 4: Persistence
    return s.repo.Save(newUser)
}

Notice what is missing:

  • No c.JSON. The service returns error or struct. 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.

1
2
3
4
5
6
type DBStore interface {
    GetUserStore(key string) (interface{}, error)
    SetUserStore(key string, value interface{}) error
    CountUsers() (int64, error)
    // ...
}

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:

1
2
3
4
5
6
7
8
9
// mocks/db_mock.go
type MockDB struct {
    users map[string]interface{}
}

func (m *MockDB) SetUserStore(key string, val interface{}) error {
    m.users[key] = val
    return nil
}

Now our unit tests run in milliseconds.

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.

1
2
3
4
5
6
type Store struct {
    User         User          // The Public Profile
    ReferalCodes []ReferalCode // Internal Referral Data
    Disable      bool          // Admin Flag
    Lifetime     time.Time     // TTL for temp users
}

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.

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.

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.

  1. Repository: Returns mongo.ErrNoDocuments.
  2. Service: Wraps it: fmt.Errorf("user not found: %w", err).
  3. 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.

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.

LayerOwnsReceivesNever touches
Handlerthe echo.Context, binding, validationdecoded []byte, bound structbusiness rules, the DB
Servicebusiness rules, encryption, the blind indexplain data structs, context.Contextecho, HTTP status codes
Repositorythe DB handle, key wrappingdomain structsthe web, validation
Middlewarerequest shape (IP, rate, token)the raw requestbusiness 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:

  1. Isolation: Bugs in the DB layer don’t break the HTTP layer logic.
  2. Clarity: New developers know exactly where to put new code.
  3. Confidence: We can test every piece in isolation.

This is the standard for professional Go engineering.


  • internal/ enforces the boundary the compiler can’t see. It is the only thing standing between your handler and a third party importing it — and between your service and echo. The folder is the API contract; the import path is the proof.
  • One constructor per struct, one composition root. SetupServer is 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.JSON or 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.