--- title: "Building a Real Service with Plumego: From HTTP Entry to Domain Logic" author: "Birdor Engineering" slug: …

---
title: "Building a Real Service with Plumego: From HTTP Entry to Domain Logic"
author: "Birdor Engineering"
slug: "plumego-real-world-service-walkthrough"
date: 2025-12-30T23:30:00+08:00
lastmod: 2025-12-30T23:30:00+08:00
description: "A deep, end-to-end walkthrough of building a real-world backend service with Plumego. This article traces a request from HTTP entry, through routing, middleware, context, use cases, domain logic, and infrastructure, explaining architectural decisions and trade-offs along the way."
keywords:
  - plumego
  - go backend example
  - clean architecture go
  - real world service
  - birdor
tags:
  - Go
  - Backend
  - Architecture
  - Practical Guide
categories:
  - Engineering
toc: true
draft: false
---

Building a Real Service with Plumego: From HTTP Entry to Domain Logic

Introduction: Why “Real” Examples Matter

Many framework tutorials stop at the point where things become interesting.

They show:

  • How to start a server
  • How to define a route
  • How to return JSON

What they rarely show is how a service behaves after six months of development, when:

  • Business rules have multiplied
  • Multiple engineers touch the same code
  • Observability matters
  • Refactoring becomes inevitable

This article is intentionally long and detailed. Its goal is not to demonstrate Plumego’s API surface, but to demonstrate how Plumego supports a real service architecture—one that is meant to evolve.

We will walk through a complete request lifecycle, from HTTP entry to domain logic and back, while continuously answering one question:

Why is this structured this way, and what problem does it solve later?


The Scenario: A User Management Service

To keep the discussion concrete, we will build a simplified but realistic User Management Service.

Functional Scope

The service will support:

  • Creating users
  • Fetching users by ID
  • Basic authentication context
  • Persistence via a repository abstraction

We will not focus on:

  • UI concerns
  • ORMs or specific databases
  • Authentication protocols in depth

The focus is architecture and flow, not feature completeness.


Architectural Principles Before Code

Before writing any code, we establish a few non-negotiable principles. These principles are more important than any individual file.

Principle 1: Transport Is Not Business Logic

HTTP, JSON, headers, and status codes are transport concerns.
Business rules must not depend on them.

Principle 2: Domain Logic Is Framework-Agnostic

The domain layer should not import Plumego.
It should be testable without a server.

Principle 3: Explicit Boundaries Beat Convenience

Every boundary—transport, use case, domain, infrastructure—should be visible in code.

Plumego does not enforce these principles, but it rewards teams that adopt them.


High-Level Request Flow

Before diving into files, let us outline the flow of a single request:

  1. HTTP request arrives at the server
  2. Middleware chain executes
  3. Route matches and handler is invoked
  4. Handler parses input and delegates
  5. Use case orchestrates domain logic
  6. Domain entities enforce invariants
  7. Repository persists data
  8. Response is constructed and returned

Nothing here is novel. What matters is where each responsibility lives.

Image

Image

Image


Project Structure: A Pragmatic Layout

Plumego does not dictate project structure, but certain layouts work especially well.

cmd/
  server/
    main.go

internal/
  app/
    application.go
    wiring.go

  transport/
    http/
      router.go
      middleware.go
      handlers/
        user_handlers.go

  usecase/
    user/
      create_user.go
      get_user.go

  domain/
    user/
      entity.go
      errors.go
      repository.go

  infra/
    repository/
      user_repository_memory.go

pkg/
  logging/
  auth/

Why This Structure Scales

  • cmd owns process concerns
  • app is the composition root
  • transport is replaceable
  • usecase expresses intent
  • domain is pure
  • infra contains side effects

Plumego integrates cleanly because it stays at the transport and lifecycle boundary.


Step 1: Process Entry and Application Startup

main.go: Keep It Boring

func main() {
    cfg := LoadConfig()

    app := app.NewApplication(cfg)

    if err := app.Run(); err != nil {
        log.Fatal(err)
    }
}

This file should answer only one question:

How does the process start and stop?

Anything more is a smell.


Step 2: Application as the Composition Root

The application layer wires everything together.

type Application struct {
    server *plumego.Server
}

func NewApplication(cfg Config) *Application {
    srv := plumego.NewServer(plumego.ServerConfig{
        Addr: cfg.HTTPAddr,
    })

    registerMiddleware(srv)
    registerRoutes(srv)

    return &Application{server: srv}
}

Why This Matters

  • Dependencies are visible
  • No hidden global state
  • Easy to test startup logic
  • Clear lifecycle ownership

Frameworks that hide composition often create long-term rigidity.


Step 3: Middleware — The First Line of Reality

Middleware defines cross-cutting concerns. In Plumego, order is explicit and meaningful.

func registerMiddleware(srv *plumego.Server) {
    srv.Use(recovery.Middleware())
    srv.Use(requestid.Middleware())
    srv.Use(logging.Middleware())
    srv.Use(auth.Middleware())
}

Reading Middleware as Documentation

From this list alone, you can infer:

  • Requests are recoverable
  • Every request has an ID
  • Logs are structured
  • Authentication runs before handlers

This is architectural clarity through code.


Step 4: Routing as an Explicit Map

Routes are registered explicitly.

func registerRoutes(srv *plumego.Server) {
    srv.Route(func(r *plumego.Router) {
        api := r.Group("/api")

        api.POST("/users", handlers.CreateUser)
        api.GET("/users/:id", handlers.GetUser)
    })
}

There is no scanning, no auto-binding, no inference.
You can answer “what endpoints exist?” by reading one file.


Step 5: Handlers as Thin Adapters

Handlers sit at the boundary between HTTP and application logic.

Create User Handler

func CreateUser(ctx *plumego.Context) error {
    var req CreateUserRequest
    if err := ctx.BindJSON(&req); err != nil {
        return err
    }

    user, err := createUserUC.Execute(ctx.Context(), req.Email)
    if err != nil {
        return err
    }

    return ctx.JSON(201, user)
}

What the Handler Does Not Do

  • It does not validate business rules
  • It does not manage persistence
  • It does not know database details

Handlers are intentionally boring.
Boring code is reliable code.


Step 6: Context as a Contract

Plumego’s context wraps context.Context but does not replace it.

Authentication Middleware Injects Context

func Middleware() plumego.Middleware {
    return func(next plumego.HandlerFunc) plumego.HandlerFunc {
        return func(ctx *plumego.Context) error {
            userID := extractUserID(ctx.Request())
            ctx.Set("user_id", userID)
            return next(ctx)
        }
    }
}

Downstream Usage

userID, ok := ctx.Get("user_id")

In more advanced systems, this is wrapped in typed helpers.
The key point: context flow is explicit.


Step 7: Use Cases as Application Logic

Use cases orchestrate business actions.

type CreateUserUseCase struct {
    repo user.Repository
}

func (uc *CreateUserUseCase) Execute(
    ctx context.Context,
    email string,
) (*user.User, error) {

    u, err := user.New(email)
    if err != nil {
        return nil, err
    }

    return uc.repo.Save(ctx, u)
}

Why Use Cases Matter

  • They encode intent
  • They are framework-independent
  • They are testable in isolation
  • They form the backbone of business logic

Plumego encourages this separation by not providing a “controller-service-repository” abstraction.


Step 8: Domain Logic — Where Rules Live

User Entity

type User struct {
    ID    string
    Email string
}

func New(email string) (*User, error) {
    if !strings.Contains(email, "@") {
        return nil, ErrInvalidEmail
    }

    return &User{
        ID:    generateID(),
        Email: email,
    }, nil
}

Domain Errors

var ErrInvalidEmail = errors.New("invalid email")

The domain:

  • Has no HTTP knowledge
  • Has no framework imports
  • Enforces invariants centrally

This is the most important long-term payoff.


Step 9: Repository Abstraction

type Repository interface {
    Save(ctx context.Context, u *User) (*User, error)
    FindByID(ctx context.Context, id string) (*User, error)
}

The use case depends on the interface, not the implementation.

In-Memory Implementation

type MemoryRepository struct {
    data map[string]*User
}

Plumego does not care what database you use.
That decision stays at the edge.


Step 10: Error Translation at the Boundary

Domain errors are not HTTP errors—until the boundary.

func ErrorMiddleware() plumego.Middleware {
    return func(next plumego.HandlerFunc) plumego.HandlerFunc {
        return func(ctx *plumego.Context) error {
            err := next(ctx)
            if err == nil {
                return nil
            }

            switch err {
            case user.ErrInvalidEmail:
                return ctx.JSON(400, ErrorResponse{Message: err.Error()})
            default:
                return ctx.JSON(500, ErrorResponse{Message: "internal error"})
            }
        }
    }
}

This keeps:

  • Domain logic pure
  • Transport logic localized
  • Error behavior consistent

Step 11: Observability Emerges Naturally

Because the flow is explicit:

  • Logs can attach request IDs
  • Traces can wrap use cases
  • Metrics can be added at boundaries

No hidden hooks are required.


Step 12: Graceful Shutdown and Lifecycle

Plumego exposes lifecycle control explicitly.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

go app.Run()

<-ctx.Done()
app.Shutdown()

Production behavior is visible and controllable.


Why This Design Holds Up Over Time

After months of development:

  • New engineers can follow the flow
  • Refactors are localized
  • Debugging starts with code, not guesswork
  • Architecture remains readable

This is the cumulative benefit of explicit systems.


Common Anti-Patterns to Avoid

Even with Plumego, teams can sabotage themselves.

  • Putting business logic in handlers
  • Using context as a global variable
  • Introducing reflection-heavy helpers
  • Recreating “magic” abstractions

Plumego does not prevent these mistakes.
It simply makes them obvious.


Final Reflection: Plumego as an Enabler, Not a Crutch

This walkthrough shows that Plumego does very little—and that is its strength.

It does not:

  • Decide your architecture
  • Hide complexity
  • Optimize for shortcuts

Instead, it provides:

  • A clear runtime skeleton
  • Explicit extension points
  • Predictable behavior

If you value systems that remain understandable long after their initial authors are gone, this approach scales.


Real services do not fail because they lack features.
They fail because they lose clarity.

Plumego is built to protect that clarity, one explicit decision at a time.

Keep Reading

Follow the engineering thread

Get the next practical Birdor note, or browse the archive for related systems, tooling, and architecture work.

Join newsletter Browse articles