I spent most of my career in PHP and JavaScript, the last few years mostly with Laravel and Symfony. At some point I had enough of it and wanted to try a new language. My choice was Go.

Laravel and Symfony decide where things go: controllers here, models there, services wherever the framework docs say. You never really think about structure because the framework already did. Go has frameworks too, but the community does not lean on them the way PHP does, and I decided not to use one while learning. That left me with a question PHP never asked me: where does the code go?

Go gives you nothing here. The compiler cares about packages and imports, and that is it. There is no app/Http/Controllers, no "right" place for business logic, and every blog post you find proposes a different layout. Half of them are a port of Java clean architecture with seven layers of interfaces, and the other half are a single main.go with 3000 lines.

This year I shipped two real Go projects:

  • Yearfold - a desktop app (Wails + React) that organizes a photo library on disk. SQLite, filesystem, EXIF, background workers. The repository is public, so every folder discussed below can be opened.
  • PlexInvoice - a multi-tenant invoicing SaaS. Go API with chi and pgx on Postgres, React frontend, Docker deploy. The repository is private; the code in this post is taken from it unchanged.

They look nothing alike on the surface, but they ended up following the same handful of rules. This post is those rules, explained one by one with the reasoning behind each, the two layouts with every folder accounted for, and a dedicated section for the most common case if you are only building an API.

Naming the folders

Folder names are the least important part in a technical sense: the compiler does not care what you call them. I still think it is good practice to have well-reasoned names, so each one below comes with its reason. These six rules are what actually keep a Go codebase readable after a year. If you understand the reason behind each, you can bend the folder names to your project without breaking anything that matters.

1. cmd/ for binaries, internal/ for everything else

myproject/
├── cmd/
│   └── server/
│       └── main.go      # one folder per binary
├── internal/            # everything the binary needs; nobody outside can import it
├── go.mod
└── Makefile

Let me go through each of these folders.

internal/

Maybe the most misunderstood folder in Go, so it is worth being precise about what it is. It is not "the place for private libraries". It is a visibility rule enforced by the compiler: a package whose import path contains an internal element can only be imported by code rooted at the parent of that internal folder. Nothing more.

For a module named plexinvoice.com that means:

plexinvoice.com/internal/api     importable only from code inside plexinvoice.com/...
plexinvoice.com/api              importable by anyone: go get plexinvoice.com/api
What internal/ actually does

So the question to ask about every package is not "is this a library?" but "should anyone outside this module be allowed to import it?". For the HTTP layer of an application the answer is obviously no. Nobody outside should depend on your handlers, your middleware or your router. If api sat at the root, another repository in the company could import "plexinvoice.com/api", and from that moment you could not change a handler signature without breaking their build. internal/ makes that impossible at compile time.

That is why, in an application, all code goes under internal/: HTTP, services, storage, domain, adapters. None of it is meant to be imported by anyone else. The only thing that stays outside is cmd/. The Go team's own "Organizing a Go module" document gives exactly this layout for a server project: cmd/ on top, everything else under internal/.

Libraries use internal/ the other way around. Their public API lives at the root or in named packages (yaml.Unmarshal, chi.NewRouter), and internal/ holds the helpers the author does not want to promise as stable. That is where the "private libraries" idea comes from: in a library it is true, in an application internal/ is simply where everything lives.

If you come from Laravel: internal/ is app/. Nobody installs someone else's Laravel project through Composer to reuse its controllers. Go just lets the compiler enforce that instead of relying on convention.

cmd/

What goes in cmd/? One folder per executable, each containing a package main. A project that ships an API server, a CLI for admins and a migration tool has cmd/server, cmd/admin and cmd/migrate. Each main.go is tiny: it parses flags, loads config, wires the dependencies from internal/ and runs. If two binaries share wiring code, that code moves into an internal/ package, never into one cmd/ importing another (which would not compile anyway).

Why there is no pkg/ folder, at any level. The pkg/ folder comes from a popular but unofficial GitHub repository called golang-standards/project-layout. Russ Cox, who led the Go team for years, publicly said it is not a standard and overcomplicates most projects. In every project I saw it in, pkg/ became a dumping ground: anything that did not obviously belong somewhere else went there, and after a year nobody knew what was reusable and what was not. If something is genuinely reusable outside the project, it deserves its own module with its own version. If it is not, it belongs in internal/.

2. Package by capability, not by layer

This is the one that PHP and Java developers get wrong first, me included. The instinct is:

internal/
├── models/
├── services/
├── repositories/
├── controllers/
├── dto/
└── utils/

That is a layer-first layout, and in Go it fails in three specific ways.

It produces import cycles. Go forbids package A importing B while B imports A, and the compiler refuses to build until you fix it. With layer packages this happens constantly: models needs a validation helper from utils, utils needs the User type from models, and you are stuck. The usual "fix" is to move things around until it compiles, and the result is code placed where the compiler allowed it rather than where it belongs.

It forces you to export everything. In Go, a lowercase identifier is private to its package. When the type, the logic that uses it and the storage that persists it live in three different packages, every field and every helper must be exported so the other layers can see it. The package boundary stops meaning anything.

It grows a utils package forever. A package named after a layer has no natural limit. Nothing tells you when utils is done. In Yearfold and PlexInvoice there is no utils package, and the few helpers that are genuinely generic (id generation, money, error types) each have a package named after what they do.

Instead, name packages after what they do. This is the approach Ben Johnson described in "Standard Package Layout" back in 2016, and I have not found a reason to deviate from it:

internal/
├── invoice/      # what an invoice is, what makes it valid, how totals are computed
├── scanner/      # walks a directory and finds changes
├── thumbs/       # generates and caches thumbnails
├── memstore/     # in-memory implementation of a store
└── api/          # exposes notes over HTTP

The test for a good name: the package name should read well in front of the type or function. 

Here are some examples: 

  • note.Note 
  • memstore.Store
  • scanner.Changes
  • organize.ByDate
  • http.Server
  • bytes.Buffer
  • sql.DB
  • json.Marshal

If you end up writing note.NoteModel or memstore.MemStore, that repetition (the Go community calls it stuttering) means either the package is named wrong or the type is.

Where types live. A type belongs in the package that owns its rules. invoice.Invoice lives next to invoice.ComputeTotals and invoice.Status, not in a models package that every other package imports. This is also what makes small packages possible: the package can keep its fields unexported and expose only the operations that make sense.

When a package grows. Split it by sub-capability, not by layer. If invoice gets too big, the split is invoice and invoice/numbering or invoice/pdf, never invoice/models and invoice/services.

Plural names. A package is plural only when it is a collection of similar, independent things: handlers holds one handler per area and that is fine. A package that provides one capability is singular: scanner, not scanners.

Names I forbid in my projects, with the reason for each:

  • models, entities, dto: types belong with their rules, not in a shared bucket.
  • services, repositories, controllers: layers are not capabilities. If you need a layer folder for an API (and sometimes you do, see Layout 2), the packages inside it are still named by area: service/clients, service/invoices.
  • utils, helpers, common, base, shared: a name that says nothing about what is inside. If you cannot name the package after a capability, you have not understood what it does yet.

3. Interfaces belong to the consumer

This is the single most important architectural rule in Go, and the one that is most different from PHP. I first saw it spelled out in Dave Cheney's "SOLID Go Design" talk; the community's short form is "accept interfaces, return structs".

In PHP you write InvoiceRepositoryInterface next to InvoiceRepository, add implements, and bind them in a container. The interface is defined by the implementation's author and describes everything the implementation can do.

In Go you declare the interface where it is used, and it contains only the methods that caller needs:

// internal/note/service.go

// A Store persists notes.
type Store interface {
    Put(ctx context.Context, n Note) error
    Get(ctx context.Context, id string) (Note, error)
    List(ctx context.Context, limit int) ([]Note, error)
}

type Service struct {
    store Store
}

func NewService(store Store) *Service {
    return &Service{store: store}
}

The package memstore (or tomorrow pgstore) does not know this interface exists. It just has methods with the same signatures. Go's implicit interface satisfaction means there is no implements keyword and no import between the two packages. main picks which one to plug in.

Why the consumer side is the right side. The consumer knows what it needs; the implementation does not know who will call it. If the note package needs three methods, the interface has three methods, no matter that pgstore has thirty. The implementation can grow without touching the interface. A second consumer that needs a different subset declares its own interface. And because the implementing package never imports the interface, the dependency arrow points from consumer to nothing: note does not import memstore and memstore does not import note.Store.

Catching a mismatch at compile time. Because satisfaction is implicit, a typo in a method signature only surfaces where the implementation is passed to the consumer. If you want an earlier error, put one line in the implementation's test file:

var _ note.Store = (*memstore.Store)(nil)

It compiles to nothing and fails the build the moment the signatures drift.

Interfaces are small. One to three methods. The standard library's most used interfaces have one: io.Reader, io.Writer, fmt.Stringer, http.Handler. A Repository with 25 methods is a Java habit. The practical reason is testing: a three-method interface has a fake that fits in ten lines. A 25-method interface needs a code generator, and the moment you need a code generator to write a test, the design has already lost.

Accept interfaces, return structs. NewService takes a Store interface and returns *Service, a concrete type. Do not return NoteServicer. Returning a concrete type means callers see the methods in their editor and in go doc, you can add a method without breaking anyone, and the caller who needs an interface can declare their own (rule above). Returning an interface means every new method is a breaking change for everyone who implemented it.

When not to create an interface. When there is one implementation and no test that needs a substitute, use the concrete type. Yearfold's library.Library holds a *store.Store, not a Store interface, because there is exactly one store (SQLite) and tests use the real one in a temporary directory. An interface with a single implementation is indirection with no payoff. Add it the day the second implementation or the first fake appears.

4. main is the composition root

There is no DI container in Go and you do not need one. main builds the dependency graph by hand, and it is the only place that knows which concrete implementation goes where. The run function below is Mat Ryer's pattern from "How I write HTTP services in Go after 13 years", and what follows is a trimmed version of it. His run also takes stdin, stdout and getenv; I have not needed them yet:

func run(ctx context.Context, args []string, stderr io.Writer) error {
    fs := flag.NewFlagSet("server", flag.ContinueOnError)
    fs.SetOutput(stderr)
    addr := fs.String("addr", ":8080", "address to listen on")
    if err := fs.Parse(args); err != nil {
        return err
    }

    cfg, err := config.Load()
    if err != nil {
        return err
    }

    store := memstore.New()                 // swap for pgstore.New(db): one line, only here
    svc := note.NewService(store)
    srv := &http.Server{
        Addr:              *addr,
        Handler:           api.NewServer(svc, log).Handler(),
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
    defer stop()

    errCh := make(chan error, 1)
    go func() { errCh <- srv.ListenAndServe() }()

    select {
    case err := <-errCh:
        if errors.Is(err, http.ErrServerClosed) {
            return nil
        }
        return err
    case <-ctx.Done():
        shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
        defer cancel()
        return srv.Shutdown(shutdownCtx)
    }
}

func main() {
    if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
        slog.Error("server failed", "err", err)
        os.Exit(1)
    }
}

Every line of this is there for a reason, so let me go through it.

main does two things. It calls run and turns an error into an exit code. That is all. log.Fatal and os.Exit appear nowhere else in the program, because both skip every pending defer: your database pool is not closed, your in-flight requests are cut, your graceful shutdown never runs. By returning errors up to main, the whole program unwinds normally.

run takes ctx, args and stderr. This makes it testable. A test can call run with a cancellable context, a custom argument list and a buffer for output, then cancel the context and assert that it returned cleanly. With the globals os.Args and os.Stderr baked in, you can't.

flag.NewFlagSet instead of the global flag package. Same reason: the global flag set is process-wide state. A flag set created inside run is local, parses the args it was given, and does not fight with the test binary's own flags.

The wiring block is the composition root. memstore.New() into note.NewService into api.NewServer. Replacing the store with Postgres is one line here and zero lines anywhere else. When wiring grows past about 50 lines (PlexInvoice has a dozen services), split it into a newHandlers function in the same package. Still no framework.

Timeouts on http.Server. A server without them is a denial-of-service waiting to happen: one client that opens a connection and sends headers one byte per minute holds a goroutine forever. ReadHeaderTimeout bounds that, WriteTimeout bounds slow responses, IdleTimeout closes keep-alive connections nobody is using.

signal.NotifyContext. Ctrl+C or a SIGTERM from the orchestrator cancels ctx. That single cancellation is what every background job and the shutdown logic listen to. There is no separate "shutdown flag".

errCh is buffered with capacity one. ListenAndServe returns exactly once. With an unbuffered channel, if run has already left the select because the context was cancelled, the goroutine would block forever trying to send. The buffer lets it send and exit.

http.ErrServerClosed is not an error. It is what ListenAndServe returns after Shutdown was called. Treating it as a failure would make every clean shutdown exit with code 1.

context.WithoutCancel for the shutdown context. The parent ctx is already cancelled at this point. Deriving the shutdown deadline from it directly would give a context that is already dead, and Shutdown would return immediately without draining in-flight requests. WithoutCancel keeps the values (request id, tracing) but drops the cancellation, and the new timeout gives the server ten seconds to finish what it was doing.

Background jobs follow the same pattern. An hourly "mark invoices overdue" job in PlexInvoice is a goroutine started in run with the same ctx. When the signal arrives, ctx.Done() fires inside its loop and it returns. Nothing is left running.

5. Dependencies point one way

Whatever layers you end up with, imports go in one direction only. In PlexInvoice it is:

api  →  service  →  storage  →  domain

Concretely, what each layer may import:

Layer
May import
Must never import
domain
standard library, helper packages (apperr, money)
anything else in the project
storage
domain, helper packages, the database driver
api, service
service
domain, storage, helper packages
net/http, the database driver
api
service, domain types for responses, helper packages
the database driver, SQL of any kind
cmd/
everything, it does the wiring
nothing is forbidden

How you notice a violation. Usually as an urge, not a compiler error: "I need the *http.Request in the service to read the user id" or "I need pgx.Tx in the handler to wrap two calls". Both urges mean something is in the wrong place. The user id belongs in a plain string argument that the handler extracts and passes down. The transaction belongs in the service, which is the layer that knows which operations form one unit of work.

How to enforce it mechanically. The depguard linter in golangci-lint can forbid specific imports per path. A rule that says "files under internal/service may not import net/http or github.com/jackc/pgx" turns the convention into a CI failure. I have not needed it yet because the review catches it, but on a larger team I would add it.

Why one direction matters. Each layer can be understood and tested without the ones above it. domain tests need nothing. service tests need a fake store. storage tests need a database but no HTTP. The moment an arrow points up, that isolation is gone and every test needs everything.

6. Errors carry context and get mapped once

Go's error handling is explicit and verbose, and the structure only works if everyone follows the same four habits. Most of them trace back to Dave Cheney's "Don't just check errors, handle them gracefully" and the errors package changes in Go 1.13.

Wrap with context, every time.

return fmt.Errorf("invoices: load %s: %w", id, err)

The prefix says which package and which operation. %w wraps the original so errors.Is and errors.As still find it three layers up. Lowercase and no trailing punctuation, because this message will be nested inside another message: "clients: create: invoices: load 42: connection refused" reads as one sentence. An unwrapped error that says only "connection refused" is useless at 3 a.m. when you do not know which of forty queries produced it.

Sentinel errors, not string comparison. A package that can fail in a way the caller must distinguish declares it:

var ErrNotFound = errors.New("not found")

The caller checks errors.Is(err, storage.ErrNotFound). Never strings.Contains(err.Error(), "not found"). Error text is for humans and changes; sentinels are for code and do not.

One typed error for the client, mapped in one place. In an API, the service returns a typed *apperr.AppError for anything the client should see (not found, validation, conflict, forbidden) and a wrapped plain error for everything else. The HTTP layer maps that to a status code in exactly one function. No handler ever writes w.WriteHeader(500) itself. If error mapping is spread over twelve handlers, every new error kind is twelve edits and one of them will be wrong.

Log once, at the edge. The service does not log the error and return it; that produces the same failure in the log twice, three times, once per layer. Services return errors. The handler adapter logs the final error once, with the request id, and writes the response.

Two smaller rules that follow: errors.Join collects several validation errors into one, which is cleaner than returning the first one you find. And panic has no place in application code; the only recover is in the HTTP middleware that turns a panic into a 500 and a log line instead of a dead process.

Those six rules are the whole philosophy. Now the two layouts.

Layout 1: a desktop app (Yearfold)

Yearfold is a Wails app. The Go side indexes files, reads EXIF, hashes for duplicates, moves files into year folders, and exposes an API that the React UI calls through Wails bindings. There is no HTTP, no users, no tenants, no authorization. The code is at github.com/sjakovic/yearfold if you want to follow along.

yearfold/
├── main.go                  # Wails wants main at the root; fine
├── frontend/                # React + Vite, built into the binary
├── build/                   # platform packaging (plist, ico, nsis)
├── internal/
│   ├── app/                 # the API the UI calls: one file per screen area
│   │   ├── app.go           #   App struct, lifecycle, progress events
│   │   ├── browse.go
│   │   ├── files.go
│   │   ├── labels.go
│   │   ├── export.go
│   │   └── changes.go
│   ├── library/             # opens a library, orchestrates the packages below
│   ├── scanner/             # walks the folder, diffs against the index
│   ├── store/               # SQLite index: files, tags, albums, undo journal
│   ├── meta/                # EXIF/IPTC/XMP read + write, Google Takeout JSON
│   ├── thumbs/              # thumbnail generation and cache
│   ├── organize/            # plans "move into year folders"
│   ├── fileops/             # move, trash, restore, undo on disk
│   ├── settings/            # user preferences on disk
│   ├── assets/              # serves thumbnails to the webview
│   └── testutil/            # test helpers: write a JPEG, write a file
├── .golangci.yml
├── wails.json
└── go.mod
Desktop app layout

Package by package

main.go embeds the built frontend, creates the app.App, and hands both to Wails. It sits at the repository root because the Wails tooling expects it there. That is a framework constraint, not a preference; in a plain CLI it would be cmd/yearfold/main.go.

app is the boundary. It is the only package that imports the Wails runtime. Every exported method on app.App becomes a function the React side can call, so the package is split into one file per screen area: browse.go for the grid, labels.go for tags and albums, export.go for the export dialog. Each method translates a UI call into one or two library operations and emits progress events back to the webview. If I ever replaced Wails with something else, app and main.go are the only files that change.

library is the orchestrator. library.Open(root) creates the hidden .yearfold/ folder, opens the SQLite index, creates the thumbnail and trash directories, and returns a *Library that holds a *store.Store, a *thumbs.Manager and a *fileops.Ops. Methods like Scan, ExtractMeta and HashPending sequence the capability packages: scan the disk, diff against the index, apply the changes, remove stale thumbnails. It is the one place that knows the order in which things happen.

scanner walks the folder and produces a scanner.Changes value: files that are new, modified, missing or moved compared to the index. It knows nothing about SQLite internals; it talks to the store through a few methods and returns a plain struct the caller can inspect or apply.

store is the only package with SQL. The schema lives here as a list of migrations applied on open. It exposes typed operations (ApplyScan, SaveMeta, RecordMove, PresentID) and a store.File type that the rest of the app uses. Nobody else writes a query.

meta reads and writes EXIF, IPTC and XMP, and parses Google Takeout's JSON sidecars. It works on files and byte slices and returns plain Go values. It does not know the index exists.

thumbs generates thumbnails on demand and caches them on disk. thumbs.Manager has a handful of methods: get, remove, clear.

organize is pure planning. organize.ByDate(store, root, layout) returns a Plan of moves (From, To, ID) without touching the disk. The UI shows the plan; the user confirms; only then does fileops execute it. Separating the plan from the execution is what makes "preview exactly what will move where" possible, and it makes the planning logic testable without a filesystem.

fileops moves, trashes and restores files on disk and records each operation in the store's undo journal. It is the only package that renames or deletes anything outside .yearfold/.

settings and assets are small: user preferences as a JSON file in the OS config directory, and an HTTP handler that serves thumbnails to the webview.

testutil holds three helpers (WriteJPEG, WriteFile, Exists) that tests in more than one package need. That is the whole test infrastructure. There is no mock library in the project.

One action through the packages

The user clicks "Organize by year":

  1. React calls OrganizePreview(layout) on the bound app.App.
  2. app asks library for the current store and calls organize.ByDate(store, root, layout).
  3. organize queries the store for dated files and returns a Plan with every move.
  4. app sends the plan to the UI. The user looks at it and confirms.
  5. React calls OrganizeApply(plan).
  6. app hands the plan to library, which starts a new undo batch in store, and for each move calls fileops.Move, which moves the file and sidecars on disk and records the move in the journal.
  7. library tells thumbs to drop thumbnails whose paths changed, then emits a "changed" event so the UI reloads.

Every step lives in a package named after what it does. Finding the code for any step means opening the folder with that name.

Why there are no interfaces between these packages

library holds a *store.Store, not a Store interface. fileops.Ops holds a *store.Store. There is exactly one store implementation and there will not be a second one. Tests open a real SQLite database in t.TempDir(), which takes a few milliseconds and tests the actual SQL. An interface here would be pure ceremony: one more file, one more indirection, no second implementation and no fake that would be better than the real thing.

The rule from section 3 still holds: the day a second implementation or a genuine need for a fake appears, the interface gets declared in the consumer. Not before.

When this layout stops being enough

The flat layout works as long as the packages form a tree with one orchestrator on top. It stops working when you add a second kind of boundary (an HTTP API next to the desktop UI), when operations need authorization, or when more than one orchestrator starts calling the same capabilities in different orders. At that point you are building the thing in Layout 2.

This layout is the right one for CLI tools, desktop apps, and background workers. Flat internal/, capability-named packages, one orchestrator package, one boundary package.

Layout 2: a SaaS backend (PlexInvoice)

PlexInvoice is a multi-tenant API. Every request carries a user and a current company, every query must be scoped to that company, there are transactions, authorization rules, and a frontend that evolves independently. This is where layers earn their place.

plexinvoice/
├── cmd/
│   └── server/
│       └── main.go              # load config, connect DB, build router, serve
├── internal/
│   ├── config/                  # env vars → Config struct; nothing else reads os.Getenv
│   ├── domain/                  # pure business rules, no I/O
│   │   ├── invoice/
│   │   ├── client/
│   │   ├── company/
│   │   └── authz/               # platform role + per-company role checks
│   ├── service/                 # use cases: validation, authz, transactions
│   │   ├── clients/
│   │   ├── invoices/
│   │   └── worksheets/
│   ├── storage/
│   │   └── postgres/            # every SQL statement in the app, one file per aggregate
│   │       ├── db.go            #   DB, DBTX, WithTx, ErrNotFound
│   │       ├── clients.go
│   │       ├── invoices.go
│   │       └── migrations/      #   goose .sql files
│   ├── api/
│   │   ├── server.go            # chi router + route table
│   │   ├── handlers/            # decode → one service call → encode
│   │   ├── middleware/          # auth, request id, logging, CORS
│   │   └── respond/             # JSON/error helpers, context accessors
│   ├── apperr/                  # AppError: the one error type the client ever sees
│   ├── id/                      # time-ordered unique ids
│   ├── money/                   # decimal helpers
│   ├── crypto/                  # AES for secrets at rest
│   ├── export/                  # PDF, Excel
│   ├── email/                   # Resend adapter
│   ├── oauth/                   # Google / Microsoft sign-in
│   └── i18n/
├── web/                         # React SPA, built into the Docker image
├── deploy/                      # compose + Caddyfile + deploy script
├── docs/                        # ARCHITECTURE, CODING_STANDARDS, TESTING, API
├── Dockerfile
├── Makefile
├── .golangci.yml
└── go.mod
Go API layout

The 2000-line handler

I want to be honest about how this layout came to exist, because I did not start with it.

The first version of PlexInvoice had SQL directly in the handlers. internal/api/handlers/invoices.go decoded the request, ran the queries, computed totals, and wrote the response. It was fast to write and it shipped.

Six months later that file was over 2000 lines. Every feature touched it. Tenant filtering (WHERE company_id = $1) was something you had to remember on every query, and a forgotten filter is a data leak between customers. Testing meant spinning up Postgres for everything, because there was no seam between "is this a valid invoice" and "write it to the database". Reviewing a pull request meant reading SQL, validation and JSON encoding interleaved in one function.

So I introduced the layers above and wrote them down as hard rules in docs/CODING_STANDARDS.md. The migration is still in progress: handlers that still contain SQL are listed as exclusions in .golangci.yml and get removed from the list one at a time as they are refactored. New code never adds SQL to a handler.

The lesson is not "always start with four layers". The lesson is: know what each layer buys you, and add it the moment you feel the pain, not six months after.

Folder by folder

config reads environment variables once into a Config struct with typed fields. Nothing else in the project calls os.Getenv. Development defaults are in the code; production values come from the environment. Secrets like the encryption key are validated at boot so a bad value fails on deploy rather than on the first request that needs it.

domain/<area> holds the types and the rules for one business area: what an invoice is, which status transitions are legal, how totals are computed, which fields a business client must have versus an individual. No I/O, no SQL, no HTTP. domain/authz holds the two-tier authorization rules (platform role plus per-company role) as pure functions. This layer is exhaustively unit tested because it is the cheapest thing in the project to test.

service/<area> is the use case layer. One struct per area, constructed with the storage it needs. It validates input, checks authorization, opens transactions, calls storage functions, and returns domain types. It is where "create a client" becomes "validate names, normalize country, generate id, insert client and recipients in one transaction".

storage/postgres is every SQL statement in the application. One file per table or aggregate. Migrations live in a subfolder as timestamped .sql files run by goose.

api is the transport. server.go builds the chi router and holds the route table, grouped into mountPublic, mountAccount, mountClients and so on, so a reader finds every endpoint in one file. handlers/ has one file per area. middleware/ has recovery, request id, logging, CORS, auth and the "a company must be selected" guard. respond/ has the JSON writers, the error adapter, the context accessors and pagination parsing.

Why the folder is called api and not http. HTTP is the protocol, the API is the role: the surface the application exposes to the outside. The folder holds the role. There is also a hard technical reason: in Go the package name defaults to the last path element, so internal/http would be package http, the same name as the standard library's net/http. Every file importing both would need an alias (nethttp.ResponseWriter instead of http.ResponseWriter), and Google's style guide explicitly says not to shadow standard library names. api is the conventional name in the Go community for this folder. If a second transport ever appears (gRPC, a CLI), the split is api/http and api/grpc, which is exactly why the role, not the protocol, names the folder.

apperr, id, money, crypto are small helper packages with no business meaning: the AppError type, time-ordered unique ids, decimal helpers, AES for secrets at rest. Each is its own package directly under internal/, named by what it does. There is no internal/pkg/ grouping them: that would be the same mistake as a root pkg/, one level down. apperr rather than errors for the same reason as api rather than http: do not shadow the standard library.

export, email, oauth, i18n are adapters: one package per external system or cross-cutting concern. They are imported by services that need them and know nothing about HTTP.

web, deploy, docs are not Go. The React app is built into the Docker image and served by the Go binary. deploy/ has the compose file, the Caddy config and the script that pulls a tagged image. docs/ has the architecture, the coding standards, the test plan and the API reference.

The handler layer

Decode the request, read identity from the context, call one service method, encode the response:

// Clients exposes the client (customer) endpoints. All logic is in the service.
type Clients struct {
    svc *clients.Service
}

func NewClients(svc *clients.Service) *Clients { return &Clients{svc: svc} }

func (h *Clients) Create(w http.ResponseWriter, r *http.Request) error {
    var in clients.CreateInput
    if err := respond.Decode(r, &in); err != nil {
        return err
    }
    c, err := h.svc.Create(r.Context(), respond.CompanyID(r.Context()), in)
    if err != nil {
        return err
    }
    respond.JSON(w, http.StatusCreated, c)
    return nil
}

Three things to notice:

  • The handler returns error. That is not the standard http.HandlerFunc signature. An adapter (respond.Handler, shown below) converts it. The benefit is that the handler contains no error-to-status logic at all; it just returns whatever the service gave it.
  • The handler holds a *clients.Service, the concrete type. There is one implementation and the handler tests use the real service with a fake store underneath. No interface needed here.
  • respond.CompanyID(r.Context()) is how identity arrives. The auth middleware put it there. The handler never looks at a cookie.

Zero SQL, zero business rules. A handler longer than 40 lines is doing someone else's job. The linter stops it at 60 lines or 40 statements; the review stops it earlier.

The service layer

The use case. Validation, authorization, the transaction boundary. Returns domain types. Client-visible failures are a typed *apperr.AppError; everything else is wrapped and becomes a 500:

// Service implements the client use cases on top of the storage layer.
type Service struct {
    db *postgres.DB
}

func NewService(db *postgres.DB) *Service { return &Service{db: db} }

// Delete removes a client that no invoice references.
func (s *Service) Delete(ctx context.Context, companyID, clientID string) error {
    if err := s.mustExist(ctx, companyID, clientID); err != nil {
        return err
    }
    n, err := postgres.CountClientInvoices(ctx, s.db, companyID, clientID)
    if err != nil {
        return fmt.Errorf("clients: count invoices: %w", err)
    }
    if n > 0 {
        return apperr.Conflict("Cannot delete client that is referenced by invoices")
    }
    if err := postgres.DeleteClient(ctx, s.db, companyID, clientID); err != nil {
        return fmt.Errorf("clients: delete: %w", err)
    }
    return nil
}

func (s *Service) mustExist(ctx context.Context, companyID, clientID string) error {
    ok, err := postgres.ClientExists(ctx, s.db, companyID, clientID)
    if err != nil {
        return fmt.Errorf("clients: exists: %w", err)
    }
    if !ok {
        return apperr.NotFound("Client not found")
    }
    return nil
}

Read the error paths. A database failure is wrapped with "clients: count invoices: %w" and will become a 500 with the full chain in the log. A business rule violation is apperr.Conflict(...) and will become a 409 with that exact message in the response. A missing row is apperr.NotFound(...) and becomes a 404. The service decides what kind of failure it is; it never decides the status code.

Argument order is always (ctx, identifiers, input): (ctx, companyID, clientID) here, (ctx, companyID, in) for create. Consistency makes call sites readable without opening the signature.

For an operation that touches more than one table, the service opens the transaction:

err = s.db.WithTx(ctx, func(tx postgres.DBTX) error {
    if err := postgres.InsertClient(ctx, tx, c); err != nil {
        return err
    }
    return postgres.ReplaceClientRecipients(ctx, tx, companyID, c.ID, recipients)
})

Return an error from the closure and everything rolls back. Return nil and it commits. The service does not touch Begin, Commit or Rollback directly.

The storage layer

SQL only. One file per aggregate. Every function takes a DBTX as its first argument after ctx:

// DBTX is the subset of pgx shared by *pgxpool.Pool and pgx.Tx.
type DBTX interface {
    Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
    Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
    QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

// GetClient loads one client without recipients. Returns ErrNotFound.
func GetClient(ctx context.Context, db DBTX, companyID, clientID string) (client.Client, error) {
    c, err := scanClient(db.QueryRow(ctx,
        `SELECT `+clientColumns+` FROM clients WHERE id = $1 AND company_id = $2`,
        clientID, companyID))
    if isNoRows(err) {
        return c, ErrNotFound
    }
    if err != nil {
        return c, fmt.Errorf("get client: %w", err)
    }
    return c, nil
}

The DBTX trick is the most useful thing in this layer. The connection pool and a transaction both have Exec, Query and QueryRow with identical signatures. By accepting the three-method interface instead of either concrete type, the same GetClient works when called with the pool (outside a transaction) and when called with tx inside WithTx. No duplicated "WithTx" variants of every function. This is also a textbook example of rule 3: a small interface declared by the consumer, satisfied implicitly by two types from a third-party library that have never heard of it.

WithTx itself is twenty lines in db.go: begin, run the closure, commit on nil, roll back on error, and roll back and re-panic if the closure panics so a bug cannot leave a transaction open.

Every tenant query filters by company_id. WHERE id = $1 AND company_id = $2, always, even when the id alone would be unique. This is the rule that makes multi-tenancy safe: a client from company B simply does not exist as far as company A's queries are concerned. Forgetting it is the single most dangerous bug in a SaaS, and having all SQL in one layer is what makes it reviewable.

Columns are listed explicitly, kept in a clientColumns constant next to a scanClient function, so adding a column is two edits in one file. No SELECT *: it silently breaks Scan when a column is added and makes the query's shape invisible.

Storage returns ErrNotFound, a sentinel of its own. The service turns it into apperr.NotFound("Client not found") with a message that fits the domain. The storage layer does not know what an AppError is.

The domain layer

Types, enums, status transitions, money rules, validation of the shape of things:

// ValidateNames checks the type-dependent required name fields.
func ValidateNames(errs map[string]string, typ string, companyName, firstName, lastName *string) {
	empty := func(s *string) bool {
		return s == nil || strings.TrimSpace(*s) == ""
	}
	switch typ {
	case TypeBusiness:
		if empty(companyName) {
			errs["company_name"] = "Company name is required for business clients"
		}
	case TypeIndividual:
		if empty(firstName) {
			errs["first_name"] = "First name is required for individual clients"
		}
		if empty(lastName) {
			errs["last_name"] = "Last name is required for individual clients"
		}
	default:
		errs["type"] = "Type must be 'business' or 'individual'"
	}
}

Pure functions with no I/O. The test for this function is a table of inputs and expected error counts and runs in microseconds. The same function is called by the create and update services, so the rule exists once.

The error contract

One typed error, one adapter, one JSON shape:

type AppError struct {
    Code    string            `json:"code"`
    Message string            `json:"message"`
    Fields  map[string]string `json:"fields,omitempty"`
    Status  int               `json:"-"`
    Err     error             `json:"-"`
}

func NotFound(msg string) *AppError   { return &AppError{Code: "not_found", Message: msg, Status: 404} }
func Validation(fields map[string]string) *AppError { /* 400, Fields set */ }
func Conflict(msg string) *AppError   { /* 409 */ }
func Forbidden(msg string) *AppError  { /* 403 */ }
func Internal(err error) *AppError    { /* 500, Err kept for the log, generic Message */ }
// HandlerFunc is an HTTP handler that reports failure by returning an error.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error

// Handler adapts a HandlerFunc to net/http. Client-visible errors are written
// as-is; anything else is logged with the request id and reported as a 500.
func Handler(fn HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        err := fn(w, r)
        if err == nil {
            return
        }
        var ae *apperr.AppError
        if errors.As(err, &ae) && ae.Status < 500 {
            WriteError(w, ae)
            return
        }
        slog.ErrorContext(r.Context(), "request failed",
            "method", r.Method, "path", r.URL.Path, "error", err)
        WriteError(w, apperr.Internal(err))
    }
}

The client always gets {"error": {"code": "...", "message": "...", "fields": {...}}}. The code is stable and machine-readable so the frontend can switch on it; the message is for humans; fields carries per-field validation messages so a form can highlight the right input. Internals never leak: a database error reaches the client as "internal_error" and nothing else. The full chain is logged server-side exactly once, with the request id that the middleware attached, so support can find it.

Status and Err have json:"-" because they are for the server, not the wire.

If you are only building an API

This is the case most people actually have: a JSON API, probably Postgres, probably a separate frontend. Here is the decision path I would follow today.

Start here: standard library, three packages

Since Go 1.22 the standard http.ServeMux understands methods and path parameters. For a small API you do not need chi, gin, echo or anything else.

myapi/
├── cmd/
│   └── api/
│       └── main.go          # run(): config, wiring, graceful shutdown
├── internal/
│   ├── note/                # domain + service: Note, Validate, Service, Store interface
│   │   ├── note.go
│   │   ├── service.go
│   │   └── service_test.go
│   ├── memstore/            # in-memory Store; replace with pgstore when you need it
│   └── api/                 # transport: routes, DTOs, error mapping, middleware
│       ├── server.go
│       └── server_test.go
├── Makefile
└── go.mod

note holds both the domain and the service at this size. note.go has the Note type, its Validate method and the sentinel errors ErrNotFound and ErrInvalid. service.go has the Store interface (three methods, declared here because this is the consumer), the Service struct, and Create, Get, List. Splitting domain and service into two packages when both fit in two files would be premature.

memstore is a map behind a mutex. It is the development store and the test store. It checks ctx.Err() before every operation even though it never blocks, purely so the habit is uniform when a real database arrives.

api is the transport. The same name as in the layered layout later, for the same reasons: it names the role, api.NewServer reads well, and it does not collide with net/http. Resist calling it http or handlers at this size; the name should not change when the project grows.

The transport package does three things only: decode, call the service, encode. Routing uses the standard mux patterns, and error mapping happens in exactly one function:

func (s *Server) Handler() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /notes", s.handleCreate)
    mux.HandleFunc("GET /notes", s.handleList)
    mux.HandleFunc("GET /notes/{id}", s.handleGet)
    return s.withLogging(mux)
}

func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
    n, err := s.notes.Get(r.Context(), r.PathValue("id"))
    if err != nil {
        s.writeServiceError(w, r, err)
        return
    }
    s.writeJSON(w, r, http.StatusOK, toJSON(n))
}

// writeServiceError maps domain errors onto status codes. This is the ONLY
// place where that mapping happens.
func (s *Server) writeServiceError(w http.ResponseWriter, r *http.Request, err error) {
    switch {
    case errors.Is(err, note.ErrNotFound):
        s.writeError(w, r, http.StatusNotFound, "note not found", err)
    case errors.Is(err, note.ErrInvalid):
        s.writeError(w, r, http.StatusBadRequest, err.Error(), err)
    default:
        s.writeError(w, r, http.StatusInternalServerError, "internal error", err)
    }
}

Keep the wire type separate from the domain type even here. A noteJSON struct with json tags next to the handler, and a toJSON(note.Note) noteJSON function, mean that renaming a field in the domain or adding a column in the database never silently changes your public API. It costs ten lines and saves an incident.

Middleware is a function. withLogging(next http.Handler) http.Handler wraps the mux. No annotations, no framework hooks. Auth, request ids and recovery are the same shape.

This layout handles a surprising amount. Three packages, zero dependencies, fully testable with httptest and a fake store, and a new engineer can read all of it in an afternoon.

Grow into this: the layered layout

Add layers when you hit the specific pain they solve, not before:

Add this
When
service/<area> separate from domain/<area>
Use cases need transactions, authorization, or orchestrate more than one store
storage/postgres with DBTX + WithTx
You have a real database and more than one aggregate
api/respond with a typed AppError
More than one handler file is mapping errors to status codes
api/middleware
Auth, request ids, CORS, logging exist as separate concerns
config/
More than three env vars, or any env var read outside main
chi (or similar)
You need route groups with different middleware, or nested routers
A standalone helper package (id, money, apperr)
A helper with zero business meaning is used by three packages

Each row in more detail:

Service separate from domain. In the three-package layout, note.Service lives next to note.Note. That works while the service is thin. The moment a use case needs to open a transaction across two aggregates, check who is allowed to do what, or call an email adapter after saving, it has stopped being a domain rule and become orchestration. Move it to service/<area> and leave domain/<area> pure. The signal is an import: when the package that defines Note wants to import the mailer, split.

Storage with DBTX and WithTx. With one aggregate, a pgstore package with methods on a struct is fine. With two or more aggregates that must change together (invoice plus its line items, client plus its recipients), you need transactions that span functions in different files. That is when the DBTX interface and the WithTx helper pay for themselves.

respond with a typed AppError. writeServiceError in one transport package is fine. When there are five handler files, each with its own switch over sentinel errors, the mapping has been copied five times and will drift. Replace the sentinels with a typed error carrying its own status and code, and the five switches with one adapter.

middleware as its own package. When withLogging is joined by withAuth, withRequestID, withCORS and withRecover, they are a package. Keeping them in the transport package works until the route table and the middleware stack stop fitting in one file.

config. The moment a second file reads os.Getenv, configuration has become implicit and untestable. Centralize it: one Load() into a struct, defaults in code, validation at boot.

chi. The standard mux cannot say "these twenty routes share the auth middleware and these five do not" without repeating the wrapper on each one. Route groups with per-group middleware are the one feature worth a dependency. I use chi because it is small and uses standard http.Handler everywhere, so nothing in the handlers knows chi exists except chi.URLParam.

Standalone helper packages. Only when a helper with no business meaning is needed by three or more packages. Before that, keep it in the package that uses it. When it does move out, it gets its own small package directly under internal/, named by what it does (id, money, apperr), never a pkg/ bucket.

The end state is the PlexInvoice layout above. One request through it:

One request through the layers
request
  → middleware (recover, request id, logging, CORS, auth → identity into context)
  → handler (decode body/params, read identity from ctx, one service call, encode)
  → service (validate, authorize, WithTx, call storage functions, return domain type)
  → storage (SQL with company_id filter, return domain type or ErrNotFound)
  → domain (pure rules used by service: totals, status transitions, validation)

And back: storage returns ErrNotFound, the service maps it to apperr.NotFound("Client not found"), the adapter writes a 404 with a stable code, and the client never sees a stack trace.

API-specific conventions I stick to

Each of these is small. Together they are what makes the API feel like one person wrote it.

  • Every successful body is wrapped: {"data": ...}. Lists are {"items": [...], "page": 1, "size": 25, "total": 140}. The wrapper means you can add metadata later (warnings, deprecation notices) without changing the shape of existing responses.
  • Pagination is ?page= and ?size= with defaults (1, 25) and a hard cap (100), parsed in one helper. Without a cap, one request for ?size=1000000 takes down the database.
  • Identity comes from the context, put there by the auth middleware, read by typed accessors (respond.UserID(ctx), respond.CompanyID(ctx)). Handlers never parse cookies or headers. Context keys are an unexported type so no other package can collide with them.
  • Cross-tenant access returns 404, not 403. A 403 confirms to an attacker that the resource exists and belongs to someone else. A 404 tells them nothing.
  • Request DTOs live next to the handler (or as the service's input struct) with json tags. Partial updates use pointer fields: nil means "unchanged", a pointer to an empty string means "clear it". For slices, nil means unchanged and [] means clear.
  • Money is decimal.Decimal in Go, numeric in Postgres, a string in JSON. Never float64: 0.1 + 0.2 is not 0.3 in binary floating point, and an invoice that is off by a cent is a support ticket.
  • IDs are strings, generated in Go, time-ordered so they sort by creation. Database sequences leak how many records you have and make ids guessable.
  • Time is UTC in the database, time.Now().UTC() in code, RFC 3339 on the wire. Local time in a server is a bug waiting for daylight saving.
  • Migrations are files, run by goose, never edited once applied anywhere. A migration that is already in production is history; fix it with a new one.
  • Background jobs (overdue checker, cleanup) are goroutines started in run with the same ctx, so they stop when the server does and never outlive it.

Testing an API

Four kinds of tests, each at the layer that makes it cheap:

Layer
Test type
Dependencies
domain
unit, table-driven
none
service
unit with a fake store
none
http
httptest against the real router with a fake or in-memory store
none
storage
integration against a real Postgres (testcontainers or TEST_DATABASE_URL)
Docker

Domain tests are tables of input and expected output. They run in milliseconds and there are many of them.

Service tests use a fake store: a real, tiny implementation of the three-method interface with a map inside and a field you can set to simulate a failure:

type fakeStore struct {
    notes  map[string]note.Note
    putErr error // set to simulate a database failure
}

func (f *fakeStore) Put(_ context.Context, n note.Note) error {
    if f.putErr != nil {
        return f.putErr
    }
    f.notes[n.ID] = n
    return nil
}

Fakes, not mocks. A mock records calls and verifies them, so the test breaks when you reorder two internal calls that produce the same result. A fake behaves like the real thing and does not care how it is called. It takes ten lines, needs no library, and survives refactoring.

Handler tests build the real router with the real service on top of the in-memory store, then fire requests with httptest.NewRequest and httptest.NewRecorder. They assert status codes and body shape. This is an integration test of the whole stack minus the database, and it needs no mock of anything.

Storage tests run against a real Postgres, because the only thing worth testing in SQL is whether the SQL is right. Testcontainers starts one per test run; a TEST_DATABASE_URL pointing at a local instance is the cheaper alternative.

Table-driven tests with name as the first field and t.Run(tt.name, ...). Error messages in the form Func(input) = got, want want, readable without opening the code. Inject the clock and the id generator (WithClock, WithIDGenerator) so tests are deterministic.

For a multi-tenant API, one test matters more than coverage numbers:

func TestCannotAccessOtherCompanyInvoice(t *testing.T) {
    ts := newTestServer(t)
    a := ts.SignIn(t, "a@x.com")  // owns Company A
    b := ts.SignIn(t, "b@x.com")  // owns Company B

    inv := ts.CreateInvoice(t, a, ...)

    res := ts.Do(t, "GET", "/api/invoices/"+inv.ID, b, nil)
    assert.Equal(t, 404, res.StatusCode)   // not 200, not 403
}

Run that pattern against every endpoint that takes an id. It is the test that protects your customers from each other.

Tooling that keeps the structure honest

Rules nobody enforces are suggestions. Three files enforce mine.

Makefile

dev:            # docker compose up postgres, migrate, run with hot reload
test:           # go test -race -count=1 ./...
test-unit:      # only domain and service: no Docker needed
lint:           # gofmt -l . && go vet ./... && golangci-lint run ./...
build:          # CGO_ENABLED=0 go build with the version injected via -ldflags
check:          # lint build test
migrate-up:     # goose up
migrate-create: # goose create $(NAME) sql

Nobody has to remember the goose flags or the ldflags syntax. make check before every push is the whole local workflow. -count=1 disables the test cache so "it passed" always means "it ran". -race turns on the race detector; a race it finds is a real bug, never flakiness.

.golangci.yml

version: "2"
linters:
  default: standard
  enable: [errorlint, gocyclo, funlen, lll, misspell, revive, unconvert, unparam]
  settings:
    funlen:  { lines: 60, statements: 40 }
    gocyclo: { min-complexity: 15 }
    lll:     { line-length: 140 }
  exclusions:
    rules:
      - path: _test\.go
        linters: [funlen, lll]
formatters:
  enable: [gofmt, goimports]

What each linter buys:

  • standard set (errcheck, govet, ineffassign, staticcheck, unused): an error you forgot to check, a suspicious Printf, a variable assigned and never read, dead code. Non-negotiable.
  • errorlint: catches err == ErrNotFound where it should be errors.Is, and %v where it should be %w. This is the linter that enforces rule 6.
  • funlen: a function over 60 lines or 40 statements fails the build. This single rule prevents the 2000-line handler from ever coming back.
  • gocyclo: a function with more than 15 branches is split.
  • lll: 140 characters. Long enough for a SQL line, short enough to read in a review diff.
  • misspell, unconvert, unparam: typos in comments, redundant conversions, parameters nobody uses.
  • revive: the golint successor; exported identifiers get doc comments, receivers are named consistently.
  • gofmt and goimports as formatters: formatting is not a review topic.

Exclusions are a to-do list. Test files are exempt from length limits because a table of twenty cases is a long function and that is fine. Legacy files that predate the rules get a path exclusion with a comment, and each pull request that touches one removes the touched part from the exclusion. The list only shrinks.

CI

Runs on every pull request and on main: gofmt -l (fails if any file is unformatted), go vet, golangci-lint, go build ./..., go test -race ./.... Red CI blocks merge through branch protection. The whole run takes about a minute, which is the budget: if CI gets slow, people stop waiting for it.

Hard limits

Unit
Limit
Why
File
400 lines
readable in one sitting
Function
60 lines
fits on one screen
Handler
40 lines
decode, one call, encode: anything more is misplaced logic
Parameters
5
beyond that, an input struct is clearer and extensible
Interface methods
3
beyond that, the fake needs a generator

A function that hits the limit is split, not exempted.

Things I deliberately do not do

  • No ORM. Raw SQL with pgx, or sqlc if the project is big enough. GORM hides the query, and the company_id filter is the one thing I never want hidden. It also makes "what SQL does this run" a debugging session instead of a glance.
  • No DI framework. Wire and dig solve a problem Go does not have. See rule 4.
  • No mock libraries by default. Fakes. See the testing section.
  • No init() and no package-level mutable state. A global db variable is the fastest way to untestable code, and init() runs at import time in an order you do not control.
  • No seven-layer clean architecture. Domain, service, storage, transport is already a lot. Use-case interfaces, presenters, gateways and adapters on top of that are Java cosplay.
  • No storing context.Context in a struct. It is always the first parameter. A stored context outlives the request it belonged to.
  • No web framework that replaces net/http. chi for routing groups is as far as I go; handlers stay http.Handler so anything from the ecosystem plugs in.

None of this is original. The rules are what was left after two projects showed me what breaks and what survives, and the next project will probably move a line or two again. What I am sure about is the direction: start small, let the pain decide when to add a layer, and let the linter and CI hold the line so you do not have to.

I turned these rules into a written guideline that I now use for my team at work. It covers everything above plus naming, errors, concurrency, logging, testing, and a PR checklist. It is published at github.com/sjakovic/go-guidelines: copy GO_GUIDELINES.md into your repo as docs/GO_GUIDELINES.md and adjust it to your team.

I hope you enjoyed this article. If you like my posts you can follow me on LinkedIn or Twitter, or you can join my newsletter for more content like this.