Skip to content
AgileLogics
7 min read

Project Layout & Architecture

Prerequisites: Benchmarking & profiling

Go has few rules about project structure, and that's deliberate. The tools enforce only a couple of conventions; the rest is judgement. The goal is code that's easy to navigate and change, not a layout that looks impressive.

Start small

A small tool or service can be a single main package:

myapp/
├── go.mod
├── main.go
├── handlers.go
└── handlers_test.go

Split into packages when the code gives you a reason — not on day one.

A typical service layout

As a service grows, a common shape is:

myapp/
├── go.mod
├── cmd/
│   ├── api/
│   │   └── main.go        # HTTP server entry point
│   └── worker/
│       └── main.go        # background job entry point
├── internal/
│   ├── orders/            # one package per feature/domain
│   │   ├── orders.go
│   │   ├── store.go
│   │   ├── http.go
│   │   └── orders_test.go
│   ├── users/
│   └── platform/          # shared infrastructure
│       ├── database/
│       └── httpx/
└── migrations/
  • cmd/<name>/ — one directory per binary. Each main.go stays thin: read config, construct dependencies, start the program.
  • internal/ — the one layout rule the Go toolchain enforces. Packages under internal/ can only be imported by code rooted at internal's parent, so other modules can't depend on your implementation details.
  • pkg/ — you'll see it in some projects as "public library code". It has no special meaning to the toolchain; most applications don't need it.

Package by feature, not by layer

Grouping by technical layer — models/, controllers/, services/, repositories/ — means a single feature change touches four packages, and those packages tend to end up importing each other in circles. Grouping by feature keeps related code together:

internal/orders/   # Order type, validation, storage, HTTP handlers
internal/users/    # User type, validation, storage, HTTP handlers

Name packages for what they provide (orders, invoice, slug), not for what they contain. Avoid util, common, and helpers — they become dumping grounds with no clear purpose.

Accept interfaces, return structs

Constructors return concrete types; functions that need a dependency accept the smallest interface that describes what they use. Define that interface in the consumer's package, not next to the implementation:

package orders

import "context"

// Only the methods this package needs from a user lookup.
type UserFinder interface {
    FindUser(ctx context.Context, id int64) (User, error)
}

type User struct {
    ID    int64
    Email string
}

type Service struct {
    users UserFinder
}

func NewService(users UserFinder) *Service {
    return &Service{users: users}
}

The implementation can be a concrete store with twenty methods; orders depends only on the one it calls, and tests can fake it in a few lines. Go interfaces are satisfied implicitly, so the implementation doesn't need to know the interface exists.

Wiring in main

Dependencies are constructed once in main and passed down explicitly — no framework, no global state:

func main() {
    cfg := loadConfig()
    db := mustOpenDB(cfg.DatabaseURL)

    userStore := users.NewStore(db)
    orderSvc := orders.NewService(userStore)

    mux := http.NewServeMux()
    orders.RegisterRoutes(mux, orderSvc)

    log.Fatal(http.ListenAndServe(cfg.Addr, mux))
}

If this function gets long, that's fine — it's the one place that knows how everything fits together.

Avoiding over-engineering

Patterns imported from other ecosystems often add ceremony without benefit in Go:

  • An interface for every struct "for testability", each with one implementation. Add an interface when a second implementation (often a test fake) actually exists.
  • Deep hexagonal/clean-architecture layering in a service with five endpoints.
  • Dependency injection containers. Constructors and main do the job.
  • Getters and setters on every field. Export the field if callers need it.

Common mistakes

  • Import cycles from layer-based packages. Go forbids cycles; it's usually a sign two packages belong together, or a shared type should move down.
  • Business logic in main.go that tests can't reach.
  • Package names that stutter: orders.OrderService reads better as orders.Service.
  • Copying a large "standard layout" template into a project that has three files.

Practice

A reports package calls a *billing.Client that has 15 methods, but reports only uses Invoices(ctx, month) ([]billing.Invoice, error). Refactor reports so it can be tested without a real billing client.

Show solution
package reports

import (
    "context"
    "time"

    "example.com/myapp/internal/billing"
)

// Defined here, in the consumer, with only what reports needs.
type InvoiceLister interface {
    Invoices(ctx context.Context, month time.Month) ([]billing.Invoice, error)
}

type Generator struct {
    invoices InvoiceLister
}

func NewGenerator(invoices InvoiceLister) *Generator {
    return &Generator{invoices: invoices}
}

*billing.Client satisfies InvoiceLister automatically, so main passes the real client unchanged, while tests pass a small fake whose Invoices method returns fixed data.