Apr 30, 2026·8 min read

Go service package layout for repos past one folder

Go service package layout that splits transport, domain, and storage in a simple way, so a growing repo stays easy to read and change.

Go service package layout for repos past one folder

Why one folder starts to hurt

A one-folder Go service feels nice at first. You can open the repo, scan a few files, and understand the whole thing in one pass. That works until HTTP handlers, business rules, SQL queries, and helper code all start living side by side.

Then the boundaries blur. A handler validates input, builds SQL, applies a pricing rule, logs an error, and formats the response in the same file. Nothing looks obviously wrong, but each file starts doing too much. After a while, you cannot tell whether a change belongs to the API layer, the domain logic, or the database code.

Small edits also spread wider than they should. Say you change how an order total gets calculated. In a cramped layout, that update might touch a request struct, a handler test, a SQL scan, and a JSON response shape, even if the real change is only one business rule. The cost is not just extra typing. It gets harder to review because unrelated details sit in the same diff.

Tests usually get worse next. If the only easy way to exercise logic is through an HTTP endpoint wired to a real database setup, even simple checks become heavy. A rule like "reject empty order items" should take a tiny unit test. In a mixed folder, it often turns into a test that boots routers, creates fixtures, and waits on database state. People stop writing small tests because they feel annoying.

New teammates pay the price fast. They open the repo and see decisions scattered across files with names that make sense only to the person who wrote them. Where does stock validation live? Why is one SQL query in the handler and another in a repository file? Which struct is safe to reuse, and which one exists only for JSON?

A small repo does not need ceremony. It does need clear places for different kinds of code. Once each decision has a home, changes shrink, tests speed up, and the code stops feeling slippery.

What each part should own

When a Go service grows, the pain usually starts when one package handles HTTP, business rules, and database queries at the same time. Bugs get harder to trace because every file can touch every concern. A cleaner Go service package layout gives each part one job.

Transport should deal with the outside world. It reads an HTTP request, parses JSON, checks simple input problems, and turns domain results into a response. It can know about status codes, headers, and request context. It should not decide pricing rules, permission rules, or database details.

Domain should decide what the service does. This is where you put rules like "an order needs at least one item" or "a canceled order cannot ship." The domain takes clear inputs, calls storage when it needs data, and returns clear outputs. It should not know whether the request came from HTTP, gRPC, or a background job.

Storage should read and write data. It knows SQL, table names, indexes, and transaction details. It should not shape API responses or enforce request-specific behavior. If you swap PostgreSQL for something else later, most of that change should stay inside storage.

A simple handoff looks like this:

  • Transport parses the request into a small input struct.
  • Domain checks rules and decides the next action.
  • Storage saves or loads data for the domain.
  • Transport turns the result into JSON and status codes.

Keep shared types small and obvious. If a type crosses package boundaries, give it one clear purpose. An input like CreateOrderInput is fine. A giant struct that mixes JSON tags, SQL fields, and business flags is where confusion starts.

The call direction should stay boring: transport -> domain -> storage. Let the top layer call down. Do not let storage import transport types, and do not let domain return raw database rows. If your order handler can call the domain with a plain struct, and the domain can ask storage for exactly what it needs, your repo stays easy to change without turning into ceremony.

A layout that stays small

A good Go service package layout keeps one idea in one place. Once handlers start calling SQL and business rules leak into random helpers, even a small repo gets annoying to change. You do not need a big framework to fix that.

.
├── cmd/
│   └── api/
│       └── main.go
└── internal/
    ├── app/
    │   ├── config.go
    │   └── startup.go
    ├── transport/
    │   └── http/
    │       ├── order_handler.go
    │       └── order_dto.go
    ├── domain/
    │   ├── order.go
    │   └── order_service.go
    └── storage/
        └── postgres/
            ├── order_repo.go
            └── order_row.go

cmd/api starts the server and wires everything together. It should open the database, build the router, create the services, and call ListenAndServe. If main.go grows past a screen or two, move config loading and startup helpers into internal/app so boot code stays easy to scan.

internal/transport/http is the edge of the system. It should parse requests, call the domain layer, and map results into HTTP responses. Keep HTTP details there: status codes, JSON shapes, query params, headers. Do not let this package build SQL or decide business rules.

internal/domain holds the use cases. This is where an order can be rejected, a user can be blocked, or a discount can expire. The domain layer should work with plain Go types and clear interfaces. It should not know anything about http.Request or Postgres rows.

internal/storage/postgres does one job: talk to Postgres. Put queries, scans, and row-to-domain mapping there. If the database schema changes, most of that churn should stay inside this package.

This layout stays small because each package has a narrow job, but there are still only a few places to look. For many services, that is enough. If you add a feature and need three more packages before you write the rule itself, the repo is drifting into ceremony.

How data should move between layers

A good Go service package layout keeps data changing shape at the edges, not in the middle. Transport should deal with JSON, gRPC messages, headers, and status codes. Domain code should deal with plain Go structs that describe the action you want to perform.

If an HTTP handler gets POST /orders, let that handler decode the request body into a transport type, then map it into something simple like CreateOrderInput. That input goes into the domain package. The domain code should not know whether the call came from REST, gRPC, a CLI command, or a queue.

That same rule helps on the way down. The domain asks storage for the data it needs in domain-friendly types, not raw database rows. Storage can read SQL rows, scan columns, and map them into types that make sense to the service, such as Order, Customer, or InventoryItem.

This keeps each package small and honest. Transport speaks network. Storage speaks database. Domain speaks business rules.

A simple order flow makes this clear. The handler decodes JSON like {"customer_id": "42", "items": [...]} and maps it to CreateOrderInput. The domain checks whether the customer exists, whether the items are in stock, and what the total should be. Then it calls storage methods such as FindCustomer, ReserveItems, and SaveOrder.

Errors should also change shape only at the edge. Storage can return low-level errors like duplicate records or missing rows. Domain can turn those into business errors like ErrCustomerNotFound or ErrOutOfStock. Then transport maps those to the response the client should see, such as 404, 409, or a gRPC status code.

If types from one layer leak into another, the code gets sticky fast. A handler that passes a database model straight into domain logic may feel faster on day one, but it usually makes every later change harder. Keep the conversions boring and local, and the repo stays easy to grow.

Move to it one feature at a time

Clean Up Repo Growth
Pick the next package split with a plan that fits your current codebase.

A good Go service package layout rarely starts with a rewrite. Start with the endpoint that keeps changing, because that code already steals the most time. When one handler parses JSON, checks business rules, writes SQL, and builds the response, that is the best place to split first.

Pick one path through the app and leave the rest alone. If POST /coupons/apply changes every sprint, clean up only that flow. You want one small improvement that ships this week, not a repo-wide package move that freezes work.

A simple sequence works well:

  • Keep HTTP details in the handler: request parsing, status codes, and response JSON.
  • Move the decision-making into a domain service, such as checking whether a coupon can apply.
  • Put direct database work in storage methods, even if that package starts with only one file.
  • Run tests after each move, then commit while the change is still easy to review.

The handler should end up thin and boring. Boring is good here. A handler that reads input, calls one service method, and maps the result to a response is much easier to trust than a 200-line function full of conditionals and SQL strings.

Do not try to design every package up front. In a small service repo, ceremony spreads fast. You do not need extra interfaces, generic wrappers, or a full folder tree for features that still live in one file.

Move one rule first. Then move one query. Then run the tests again. If you break something, you know exactly which step caused it.

That is usually how a practical Go project structure grows without turning into package theater. The repo stays familiar, each package gets a clear job, and the team can keep shipping while the code gets easier to change.

A simple example: create an order

An order request shows where package boundaries pay off. In a good Go service package layout, the handler deals with the wire format, the domain decides if the order is allowed, and storage saves the result.

The handler's job is small. It reads JSON, checks that the customer ID exists, makes sure at least one item is present, and rejects broken input such as a missing product ID or a quantity of zero. If the body is malformed, the request stops there. The handler should not check stock or decide whether a suspended customer can buy.

The domain service owns those rules. It loads the customer state, checks that the account can place orders, verifies stock for each item, and compares prices against the current catalog. If one item changed price since the user opened the cart, the domain returns a specific error. If stock is short, it returns a different one. Those names matter because the handler can map them cleanly.

A storage package saves the successful result in one transaction. It inserts the order row, writes the reserved items, and commits only if all records succeed. The domain does not care whether that happens in PostgreSQL or something else. It only asks the repository to persist an accepted order.

A practical mapping looks like this:

  • Broken JSON or missing fields: 400
  • Customer cannot order right now: 403 or 409, depending on the rule
  • Item is out of stock: 409
  • Price changed before checkout: 409
  • Unexpected storage failure: 500

Tests stay focused when the rule sits in the domain. You can call the order service with a fake customer store and a fake inventory store, then assert that a blocked customer gets the right error or that low stock stops the order. No HTTP server. No real database. That keeps tests fast and makes the business rule easy to change later.

Where tests fit

Bring In a Fractional CTO
Get senior help on Go architecture, team workflow, and technical choices.

A Go service package layout gets easier to maintain when tests follow the same package boundaries. If every test boots the whole app, the suite gets slow, noisy, and hard to trust.

Handler tests should stay at the transport edge. Use httptest to send requests through the handler, then check what the client would actually see: bad input should fail cleanly, valid input should return the right status code, and the response body should keep the expected JSON shape.

Keep those tests narrow. A handler test does not need to prove that pricing rules work or that a query is correct. It only needs to prove that the handler parses input, calls the next layer correctly, and writes the response you expect.

Domain tests should be the cheapest tests in the repo. They work best with plain structs and direct function calls, with no HTTP, no database, and no framework setup. If your order service rejects a zero quantity or caps a discount, test that rule there and nowhere else.

Storage tests need more realism. SQL mocks can help in a pinch, but they often let broken queries pass. For code that reads and writes real data, use a real database, usually in a container, and verify the query, scan, and mapping logic against something close to production.

These tests cost more, so keep them focused. One test for inserting an order, one for loading it back, one for a common failure case is usually enough.

End-to-end tests still matter, just in small numbers. Use a few to prove the wiring works from router to domain to storage. A happy path and one obvious error path often catch missing config, bad dependency setup, and broken request flow without forcing you to repeat every branch at the top level.

When a bug shows up, write the test at the lowest layer that can reproduce it. That keeps the suite fast and tells you exactly where the problem lives.

Mistakes that add ceremony

A repo with six packages and three business rules is harder to read, not cleaner. Good package boundaries remove confusion. Bad ones make you open five files to follow one request.

The most common mistake is adding interfaces everywhere. If one struct has one implementation and nobody swaps it in tests or at runtime, keep the concrete type. An interface should solve a real need, not decorate the code. A Store interface that exists only because "clean architecture" said so usually becomes dead weight.

Small features also get buried under too many folders. A handler, service, mapper, repository, model, dto, and validator package for one tiny endpoint is a lot of ceremony for a simple flow. If a feature has one transport input, one domain rule, and one query, keep those pieces close. Nesting can wait until the code actually grows.

Skip the junk drawer

The utils package is where clear code goes to disappear. Random helpers look harmless at first, then every package imports them, and now nothing has a clear home. Put code where it makes sense for the business or the boundary. If a function only helps storage code, keep it in storage. If it only formats an HTTP response, keep it near transport.

Copying the same type through every package creates the same kind of drag. If CreateOrderRequest, CreateOrderInput, and CreateOrderCommand all carry the same four fields, you are typing more, not modeling more. Translate types at real boundaries only. HTTP payloads often need their own shape. Database rows often need their own shape. The domain type in the middle should exist because it says something different, not because every layer must have a custom name.

A few smells show up again and again:

  • one interface per struct, even when nothing depends on the abstraction
  • deep folder trees for tiny features
  • a shared utils bucket for unrelated code
  • duplicate types that differ only by package name

The last mistake is treating one layout as a rule for every service. A small internal worker does not need the same shape as a public API with several transports and a busy storage layer. Go service package layout should follow the pressure in the codebase. Start small, split only where the seams are obvious, and let the repo earn its extra packages.

Quick checks before adding another package

Plan a Safer Refactor
Move one feature at a time and keep shipping while the repo gets easier to change.

Most new packages are born too early. In a small Go service, another folder should remove confusion now, not promise some cleaner future that may never matter.

Start with the job. If a package does one thing and you can describe it in one plain sentence, it probably has a reason to exist. "This package stores orders in Postgres" is clear. "This package has shared helpers for orders, validation, mapping, and retries" is a warning sign.

Names matter just as much. Another developer should find the code without guessing. If request parsing lives in one place, business rules in another, and database code in a third, the path should feel obvious from the folder names alone. When people keep asking "where does this go?", the layout is still muddy.

Tests give you an honest answer. A split is useful when tests get smaller and setup gets lighter. If moving code into a new package means more mocks, more interfaces, and more test fixtures, you probably added ceremony instead of clarity.

Imports are another quick signal. A healthy split cuts dependency lines. In a Go service package layout, domain code should not pull in HTTP details, and storage code should not know about JSON request shapes. If the new package adds more cross-package imports than it removes, keep the code together a bit longer.

A simple check helps before every move:

  • Give the package a one-line description.
  • Ask where a new teammate would look for it first.
  • Compare the test before and after the split.
  • Count whether imports go down or spread sideways.

If you hesitate on two or three of those, wait. Good package boundaries feel boring. People read the tree, open the file they expect, and keep moving.

Next steps for a repo that keeps growing

Once a repo has three or four features, moving files on instinct usually makes things worse. Start on paper. Write the folder tree you want before you move anything, even if it is rough. That step exposes duplicate packages, vague names, and folders that exist only because they might be useful later.

Then pick one path through the service and refactor only that path first. Use a request that starts at transport, runs domain logic, and ends in storage. A create or update flow is usually enough. If that path gets easier to read and change, you are moving in the right direction.

A practical Go service package layout should make the next edit simpler, not make the diagram prettier. Small repos do not need perfect layer purity. They need code that a tired teammate can understand on a Tuesday night without opening twelve files.

A good stopping point looks like this:

  • handlers know HTTP and request parsing
  • domain code knows business rules
  • storage code knows SQL and persistence details
  • the data flow between them is easy to trace

Stop when the code feels clearer. Do not keep splitting packages just because the shape looks more "correct." Extra seams have a cost. More packages mean more names to choose, more imports to scan, and more places for tiny types to hide.

If the team keeps circling around the same argument, bring in a fresh pair of eyes. Oleg Sotnikov reviews growing Go services as a Fractional CTO and helps teams choose a layout that fits the code they already have, instead of forcing a pattern that belongs to a much bigger system.

That is usually the right pace for this kind of refactor: sketch the tree, move one full path, keep the parts that made the service easier to follow, and leave the rest alone for now.