Project Layout and Architecture
For developers with .NET (C#) experience, this guide explains standard directory layouts, how to apply Clean/Hexagonal Architecture, and idiomatic Go design. In C#, it is standard to create multiple projects in a solution, such as Domain / Application / Infrastructure / Web. In Go, small packages inside a single module achieve the same direction of dependencies. Centered on the layout officially presented by the Go team, this guide organizes the Go way of avoiding over-abstraction.
1. What This Document Covers
- Using cmd / internal / pkg
- Avoiding over-abstraction the Go way
- Package naming and API design conventions
- Key points from Effective Go / Code Review Comments
Using cmd / internal / pkg
The Go team's official guide, "Organizing a Go module", shows an incremental layout for each kind of project. The basic approach is to start small and split only when needed.
| Layout | Example |
|---|---|
| A single command | Put go.mod and main.go at the root. As files grow, split them within the same main package |
| A command with supporting packages | main.go at the root, and internal/ for the implementation |
| Multiple commands | One cmd/server-name/main.go per executable, with shared code in internal/ |
| A reusable library | Put the library's packages at the root, and put implementation you don't want to expose in internal/ |
The role of each directory:
cmd/holds onemainpackage per executable.mainshould only read arguments and configuration, wire up dependencies, and start the program, leaving the real work tointernal/.internal/is an area where the compiler guarantees that code cannot be imported from outside the module (see Modules and Packages for details). Application implementation basically goes here.pkg/does not appear in the official guide. It is a directory that became popular as a convention for "code that outside projects may import", but it is not required, and it is unnecessary unless you are writing a library. For new applications,internal/is enough.- Avoid packages with names that don't express a role, such as
util,common, andmodels.
Avoiding over-abstraction the Go way
Go is a language where you first write with concrete types and functions, and add abstraction when it becomes necessary.
- Define interfaces in the package that uses them, not in the implementing package. Limit the methods to those the consumer actually calls (see Dependency Injection and Configuration for details).
- The basic rule is "accept interfaces, return structs". Accept interfaces as function parameters so callers can swap them easily, and return concrete types so consumers keep their options.
- Don't create an interface such as
IUserServiceup front when there is only one implementation. When tests need a mock, defining a small interface on the consumer side is soon enough. - Don't introduce mediators like MediatR or multi-tier Repository / Service / Manager structures from the start. Call functions and methods directly from handlers.
Package naming and API design conventions
- Package names are short and lowercase, without underscores or mixed caps (
orderitems, notorder_itemsororderItems). - Callers read
package.Name, so avoid repeating names (stutter). Useuser.Service, notuser.UserService. - Avoid names such as
util,common,base, andhelpers, because they become catch-all packages. - Don't prefix getters with
Get. Useuser.Name(), notuser.GetName(). The setter isSetName. - Write a doc comment that begins with the name for each exported name (
// Service handles ...). - Receiver names are short, one or two letters, and consistent across a type. Don't use
thisorself. - Error strings start with a lowercase letter and don't end with punctuation.
Key points from Effective Go / Code Review Comments
Effective Go and Go Code Review Comments are the shared reference points for writing Go code. The following points matter especially in practice.
| Topic | Key point |
|---|---|
| Error handling | Don't ignore errors; return them. Wrap them with context using fmt.Errorf("...: %w", err) |
context.Context | Make it the first parameter and don't store it in a struct (The context Package) |
| Zero values | Design types so that the zero value is usable as is |
| Early return | Keep the happy path unindented and return error cases first |
| Concurrency | Decide how a goroutine will end when you start it |
| Interfaces | Define them on the consumer side and keep them small |
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| Clean Architecture (multi-layer projects) | cmd/ + internal/ + small packages |
| Repository / Service pattern | Small interfaces defined by the consumer |
| MediatR / CQRS | Direct calls to functions and handlers are the norm |
Additional mappings that complement the table above:
| .NET (C#) | Go |
|---|---|
Solution (.sln) | A module (go.mod), or go.work for multiple modules |
Project (.csproj) = unit of reference | A package (directory) |
Program.cs / Startup | cmd/name/main.go |
| Domain / Application / Infrastructure projects | Feature-based packages under internal/ (for example order, postgres, httpapi) |
internal (confined to the assembly) | The internal/ directory (importable only within the module) |
Placing IOrderRepository in the Domain project | Placing the Repository interface in the order package (the consumer) |
| Folders per layer (Controllers / Services / Repositories) | Packages per feature (package by feature) |
| DTO / AutoMapper | Small purpose-specific structs and explicit conversion functions |
Registration via IServiceCollection | Manual wiring in main (Dependency Injection and Configuration) |
[ApiController] and ASP.NET Core | net/http and http.Handler (Web API) |
3. Common Pitfalls for C# Developers
Splitting packages by layer invites import cycles
In C#, even if you split folders by layer, such as Controllers / Services / Repositories, they can reference each other within the same assembly. Go makes import cycles between packages a compile error. If you split by layer into models, services, and repositories, references tend to tangle: services uses models, and repositories uses types from both models and services. Cutting packages by feature (order, customer, and so on) and keeping types and behavior in the same package makes this problem much less likely.
The direction of dependencies is decided by where the interface lives
In Clean Architecture, the inside (domain) does not depend on the outside (DB, HTTP). In C#, you place the interface in the domain project and the infrastructure project implements it. In Go, the consumer (for example the order package) defines an interface with only the methods it needs, while the postgres package ignores it and exposes a concrete type. Go interfaces are satisfied implicitly, so the implementing side doesn't need to import the consumer's package.
order imports no other internal package, while httpapi and postgres use order's types. main wires everything together.
Don't gather shared interfaces in one place
If you bring over the C# habit of an Interfaces folder or pairing IFoo with Foo, interfaces end up in the same package as the implementation and consumers depend on huge interfaces. Put an interface in the consuming package, as the smallest set of methods the consumer needs. A one-method interface such as the standard library's io.Reader is the ideal.
Don't make the return type an interface
In C#, it is normal for a factory to return IFoo. In Go, a constructor returns a pointer to a concrete type (func NewService(...) *Service). If you return an interface, consumers have no choice, and adding methods becomes a compatibility problem.
pkg/ and excessive nesting
Don't assume that unofficial templates such as golang-standards/project-layout are an official standard. The Go team's official guide starts from a much smaller layout. Deep directories alone don't make a better design. They lengthen import paths and blur package responsibilities.
Mixing ORM tags and attributes into domain models
If you reuse domain structs the way you would reuse Entity Framework entities and attach json and db tags to everything, persistence and API concerns leak into the domain. For a simple application, one struct is enough, but once things get complex, keep API structs and DB structs in each adapter and connect them with conversion functions.
No inheritance or abstract classes
You can't share behavior with the template method pattern or base classes. Express shared behavior with struct embedding, functions, or a combination of small interfaces. Embedding is shorthand for delegation, not inheritance, and overusing it makes the API ambiguous.
4. Code Examples
Example 1: A server project layout following the official guide
shop/
├── go.mod module example.com/shop
├── cmd/
│ ├── shop-api/
│ │ └── main.go start the HTTP server (wiring only)
│ └── shop-worker/
│ └── main.go another executable
├── internal/
│ ├── order/ domain and use cases (no external dependencies)
│ │ ├── order.go
│ │ ├── service.go
│ │ └── service_test.go
│ ├── customer/
│ ├── postgres/ implementation of order.Repository (DB adapter)
│ │ └── order_repository.go
│ ├── httpapi/ HTTP handlers and routing
│ │ └── order_handler.go
│ └── config/ configuration loading
└── migrations/ SQL migrations
What C# splits into four projects, Shop.Domain, Shop.Application, Shop.Infrastructure, and Shop.Api, Go expresses with directories inside a single module.
Example 2: Defining the interface on the consumer side (compared with C#)
C#:
// Domain project
public interface IOrderRepository
{
Task<Order?> FindAsync(int id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
// Application project
public class OrderService(IOrderRepository repository)
{
public async Task<Order> GetAsync(int id, CancellationToken ct)
=> await repository.FindAsync(id, ct)
?? throw new KeyNotFoundException();
}
// Infrastructure project
public class SqlOrderRepository : IOrderRepository { /* ... */ }
Go:
// internal/order/service.go
package order
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("order not found")
type Order struct {
ID int
Total int
}
// Repository has only the methods that Service needs.
// It is defined on the consumer side (the order package).
type Repository interface {
Find(ctx context.Context, id int) (*Order, error)
}
type Service struct {
repo Repository
}
// Accept an interface, return a concrete type
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Get(ctx context.Context, id int) (*Order, error) {
o, err := s.repo.Find(ctx, id)
if err != nil {
return nil, fmt.Errorf("get order %d: %w", id, err)
}
return o, nil
}
// internal/postgres/order_repository.go
package postgres
import (
"context"
"database/sql"
"errors"
"example.com/shop/internal/order"
)
type OrderRepository struct {
db *sql.DB
}
func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}
// Satisfies order.Repository. It imports the order package only to use its types;
// no "implements" declaration is needed.
func (r *OrderRepository) Find(ctx context.Context, id int) (*order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx, "SELECT id, total FROM orders WHERE id = $1", id).
Scan(&o.ID, &o.Total)
if errors.Is(err, sql.ErrNoRows) {
return nil, order.ErrNotFound
}
if err != nil {
return nil, err
}
return &o, nil
}
If you want to verify at compile time that a type satisfies an interface, there is an idiom of writing the following line in the postgres package.
var _ order.Repository = (*OrderRepository)(nil)
Example 3: main under cmd only wires things together
// cmd/shop-api/main.go
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"example.com/shop/internal/httpapi"
"example.com/shop/internal/order"
"example.com/shop/internal/postgres"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
// Wire dependencies by hand, from the inside out
repo := postgres.NewOrderRepository(db)
svc := order.NewService(repo)
handler := httpapi.NewHandler(svc)
srv := &http.Server{Addr: ":8080", Handler: handler}
go func() {
<-ctx.Done()
_ = srv.Shutdown(context.Background())
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
Keeping main thin and moving the work into run() error makes it easier to test and lets defer work correctly (os.Exit doesn't run deferred functions). For loading connection strings and DI details, see Dependency Injection and Configuration.
Example 4: No MediatR-style mediator
C#:
public record GetOrderQuery(int Id) : IRequest<OrderDto>;
public class GetOrderHandler(IOrderRepository repo) : IRequestHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> Handle(GetOrderQuery q, CancellationToken ct) { /* ... */ }
}
var dto = await mediator.Send(new GetOrderQuery(42), ct);
Go:
// No mediator; call the service method directly
o, err := svc.Get(ctx, 42)
Cross-cutting concerns (logging, authentication, metrics) are typically handled by wrapping with http.Handler middleware rather than pipeline behaviors (Web API).
Example 5: Shaping package names and APIs
// Avoid
package services
type UserService struct{} // services.UserService repeats itself
func (s *UserService) GetUserName() string // Get and User are redundant
// Refined
package user
type Service struct{} // user.Service
func (s *Service) Name() string // Name of user.Service
Aim for names that read naturally at the call site, such as user.Service and order.NewService.
5. Summary
- Follow the official guide and start small. Split out
internal/and thencmd/name/from a singlemain.goonly when needed.pkg/is not required. mainundercmd/only loads configuration, wires dependencies, and starts the program. The real work lives in feature-based packages underinternal/.- Splitting packages by feature rather than by layer makes it easier to avoid import cycles.
- Define interfaces on the consumer side and keep them small. Accept interfaces as arguments and return concrete types.
- Don't abstract while there is only one implementation, and don't introduce mediators like MediatR or multi-tier Repository / Service structures until the need is confirmed.
- Use short lowercase package names and avoid names such as
utilandcommon. Avoid stutter as inuser.Service, and don't prefix getters withGet. - For how to write tests, see Testing. For the specifics of modules and
internal, see Modules and Packages.