Get a quote

Structuring a Go SaaS Backend: Package Design, Interfaces, and Dependency Injection That Scales

A Go SaaS backend that starts with a handful of files eventually becomes a codebase with dozens of packages and hundreds of files. How you structure it from the beginning determines how painful that growth will be. This is how we organize Go backends that have to stay maintainable as the team and feature set expand.

A Go SaaS backend that starts with a handful of files eventually becomes a codebase with dozens of packages and hundreds of files. How you structure it from the beginning determines how painful that growth will be. The wrong structure leads to fat packages that are hard to test, circular import errors that block refactoring, and service layers that accumulate logic they should not own. This is how we organize Go backends for SaaS products we build in Lebanon and across MENA.

The two common structural mistakes

The first mistake is organizing code by technical role: a handlers package, a models package, a services package, a repositories package. This feels clean when you have two features. When you have fifteen features, every package becomes a dumping ground. The handlers package contains HTTP handlers for users, orders, inventory, billing, and reports, all mixed together. Understanding what a single feature does requires jumping between four packages.

The second mistake is the opposite: putting everything for a feature into a single file or package with no internal organization. The file grows to 2,000 lines. Functions reference each other without any defined boundaries. Testing one function requires setting up the entire feature.

Neither extreme works at scale.

Domain-first package organization

The structure we use organizes code by domain first, with technical layers inside each domain:

cmd/
  server/
    main.go
internal/
  domain/
    order/
      order.go          (domain types and business logic)
      repository.go     (interface definition)
      service.go        (use cases)
    tenant/
      tenant.go
      repository.go
      service.go
    inventory/
      ...
  postgres/
    order_repository.go (implements order.Repository)
    tenant_repository.go
  http/
    order_handler.go
    tenant_handler.go
    middleware.go
  config/
    config.go
pkg/
  pagination/
  timeutil/

Each domain package (order, tenant, inventory) contains the types, interfaces, and business logic for that domain. It does not import any implementation packages like postgres or http. The postgres package imports domain packages to implement their interfaces. The http package imports domain packages to call their services.

This structure enforces a dependency direction: http depends on domain, postgres depends on domain, but domain does not depend on either.

Defining interfaces at the point of use

Go interfaces work differently from Java or C# interfaces. In Go, an interface is defined where it is consumed, not where it is implemented. This is a deliberate language design choice that enables loose coupling without explicit declarations.

The order package defines the interface it needs from storage:

// internal/domain/order/repository.go

package order

import (
    "context"
    "time"

    "github.com/google/uuid"
)

type Repository interface {
    Create(ctx context.Context, o Order) error
    GetByID(ctx context.Context, tenantID, orderID uuid.UUID) (*Order, error)
    ListByTenant(ctx context.Context, tenantID uuid.UUID, after time.Time, limit int) ([]Order, error)
    UpdateStatus(ctx context.Context, tenantID, orderID uuid.UUID, status Status) error
}

The PostgreSQL implementation lives in a separate package and satisfies this interface implicitly:

// internal/postgres/order_repository.go

package postgres

import (
    "context"

    "github.com/google/uuid"
    "github.com/yourdomain/app/internal/domain/order"
)

type OrderRepository struct {
    db *DB
}

func NewOrderRepository(db *DB) *OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) Create(ctx context.Context, o order.Order) error {
    // ...
}

// ... other methods

postgres.OrderRepository satisfies order.Repository without any explicit implements declaration. The compiler verifies this at compile time.

The service layer: thin orchestrators

The service layer owns use-case logic: the sequence of steps needed to complete an operation. It does not own business rules (those live in the domain types), and it does not own data access (that lives in the repository). A service method should read like a clear description of what the operation does.

// internal/domain/order/service.go

package order

import (
    "context"
    "fmt"

    "github.com/google/uuid"
    "github.com/yourdomain/app/internal/domain/inventory"
)

type Service struct {
    orders    Repository
    inventory inventory.Repository
}

func NewService(orders Repository, inv inventory.Repository) *Service {
    return &Service{orders: orders, inventory: inv}
}

func (s *Service) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (*Order, error) {
    o, err := NewOrder(req.TenantID, req.CustomerID, req.Items)
    if err != nil {
        return nil, fmt.Errorf("order: build: %w", err)
    }

    if err := s.inventory.Reserve(ctx, o.TenantID, o.Items); err != nil {
        return nil, fmt.Errorf("order: reserve inventory: %w", err)
    }

    if err := s.orders.Create(ctx, *o); err != nil {
        return nil, fmt.Errorf("order: persist: %w", err)
    }

    return o, nil
}

Notice that Service depends on Repository (an interface) and inventory.Repository (another interface). It has no knowledge of PostgreSQL, Redis, or HTTP. This makes the service trivial to unit-test by swapping in in-memory implementations of the interfaces.

Dependency injection without a framework

Go does not need a DI framework. The wiring happens in main.go with plain constructor calls:

func main() {
    cfg := config.Load()
    db := postgres.NewDB(cfg.DatabaseURL)

    // Repositories
    orderRepo := postgres.NewOrderRepository(db)
    inventoryRepo := postgres.NewInventoryRepository(db)
    tenantRepo := postgres.NewTenantRepository(db)

    // Services
    orderSvc := order.NewService(orderRepo, inventoryRepo)
    tenantSvc := tenant.NewService(tenantRepo)

    // HTTP handlers
    router := http.NewRouter(
        http.NewOrderHandler(orderSvc),
        http.NewTenantHandler(tenantSvc),
    )

    srv := &server.HTTP{Handler: router, Addr: cfg.Addr}
    srv.Run()
}

This is verbose but completely explicit. You can trace any dependency from main.go outward. There is no runtime reflection, no configuration files, no container registration. When a test needs to substitute a dependency, it creates the service with a mock implementation directly.

Avoiding circular imports

Circular imports (package A imports package B which imports package A) are a compile error in Go. In a badly structured codebase, they appear frequently as the team adds features across domain boundaries.

The domain-first structure prevents most circular imports by design: domain packages do not import each other directly. When domain A needs something from domain B, it defines an interface for what it needs rather than importing the concrete type.

If the order package needs to look up a tenant for validation, it does not import internal/domain/tenant. Instead:

// in internal/domain/order/service.go
type TenantLookup interface {
    Exists(ctx context.Context, tenantID uuid.UUID) (bool, error)
}

The tenant.Service satisfies this interface, and the wiring in main.go connects them. Neither domain package imports the other.

The internal directory and API boundaries

Go's internal directory enforces package visibility at the compiler level: packages inside internal/ can only be imported by code in the parent tree. For a SaaS backend that does not expose its domain logic as a library to other repositories, wrapping everything under internal/ prevents accidental external dependencies and makes the boundaries explicit.

What lives in pkg/

The pkg/ directory holds utilities that are genuinely domain-agnostic and might be shared across the codebase or even across repositories. Good candidates: pagination helpers, custom time utilities, error type definitions, test assertion helpers. Bad candidates: anything that knows about a specific domain object.

Keep pkg/ small. Most utilities belong in the domain or implementation package they serve.

Key lessons from production

  • Organize by domain, with technical layers inside each domain package.
  • Define interfaces at the point of use, not the point of implementation.
  • Keep service methods as thin orchestrators: sequence of steps, not business rules.
  • Wire all dependencies in main.go with plain constructors.
  • Prevent circular imports by having domains define the interfaces they need rather than importing sibling domains.
  • Use internal/ to enforce boundaries and prevent accidental external imports.
  • A readable main.go that shows the entire dependency graph is better than a framework that hides it.
Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

We build Go SaaS backends for companies in Lebanon and across MENA. If you are starting a new product or dealing with a codebase that has grown beyond its structure, we can help design an architecture that stays maintainable at scale. Reach out at https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp