Packages & Project Structure

Designing Go packages correctly prevents import cycles, architectural decay, and long-term maintenance pain. This lesson covers boundaries, internal visibility, dependency inversion, and production-safe project layouts.

On this page

Why Package Structure Is an Architectural Decision

In Go, packages are not just folders. They are the primary unit of encapsulation, compilation, and dependency management. Poor package structure does not fail immediately — it degrades slowly. Over time, it produces import cycles, fragile tests, and “god packages” that nobody wants to touch.

In production systems, package structure directly affects:

  • Build stability
  • Dependency direction
  • Test isolation
  • Refactor safety
  • Onboarding speed

Real Production Failure: The Import Cycle Incident

A growing backend service had loosely defined packages: handlers imported services, services imported repositories, repositories imported logging utilities, and eventually logging utilities imported configuration that referenced service-level types.

The result:

import cycle not allowed

The team attempted quick fixes by moving functions between packages, creating a tangled graph that became harder to reason about. A full refactor was eventually required.

Lesson: dependency direction must be intentional from day one.

Baseline Production Layout

my-service/
  go.mod
  cmd/
    api/
      main.go
  internal/
    app/
    domain/
    repository/
    transport/
  pkg/
  • cmd/ contains entrypoints only.
  • internal/ contains application code not intended for external reuse.
  • pkg/ is optional and should only contain intentionally exported libraries.

The internal/ Directory Rule

Go enforces visibility rules for the internal/ directory. Code inside internal/ cannot be imported from outside the module tree. This is a powerful architectural tool.

Use internal/ to prevent accidental external coupling.

Dependency Direction: High-Level Rule

Dependencies should point inward. Infrastructure depends on domain, not the other way around.

Correct Direction

  • transport → service
  • service → domain
  • repository → domain

Incorrect Direction

  • domain → repository
  • service → transport
  • domain → logging implementation

Interface Placement in Go

In Go, interfaces are implicitly satisfied. Best practice: define interfaces where they are consumed, not where they are implemented.

Bad Pattern

package repository

type UserRepository interface {
    FindByID(id int) (*User, error)
}

This forces consumers to depend on repository-level abstractions.

Better Pattern

package service

type UserRepository interface {
    FindByID(id int) (*User, error)
}

Now service defines what it needs. The repository package provides an implementation.

Avoiding Import Cycles

Detect Cycles Early

go build ./...
go list -deps ./...

Common Causes

  • Shared utility packages that depend on higher-level code
  • Logging packages importing domain types
  • Global configuration referenced everywhere

Cycle Prevention Strategies

  • Extract shared types into neutral packages
  • Move interfaces upward
  • Separate pure domain logic from infrastructure

Domain vs Infrastructure Separation

Domain packages should not import database drivers, HTTP frameworks, or logging implementations.

Domain Package Should Contain

  • Business rules
  • Core entities
  • Validation logic

Infrastructure Package Should Contain

  • SQL implementations
  • HTTP handlers
  • External API clients

This separation increases testability and long-term stability.

Monorepo vs Multi-Module

Single Module (Common Case)

  • Simpler dependency management
  • Easier refactors
  • Unified versioning

Multi-Module Setup

  • Useful when multiple independent services exist
  • Requires strict version management
  • Increases cognitive overhead

Most production services should start with a single module unless there is a clear boundary requiring separation.

Testing and Package Design

Good package boundaries enable clean unit tests.

Example

package service_test

import (
    "testing"
)

Using external test packages (service_test) ensures you test the public API, not internal details.

Common Testing Anti-Pattern

  • Accessing unexported internals via same-package tests
  • Using global variables to bypass boundaries

God Package Anti-Pattern

Symptoms:

  • Huge package with dozens of files
  • Everything imports it
  • Refactors require touching many unrelated components

Fix:

  • Split by responsibility, not by technical layer
  • Avoid dumping shared helpers into one place

Refactoring Strategy in Growing Codebases

Step 1: Visualize Dependencies

go list -deps ./...

Step 2: Identify Cycles or Bidirectional Dependencies

Step 3: Introduce Interfaces at Boundaries

Step 4: Extract Shared Types

Refactoring packages is easier early. Delay increases cost exponentially.

Operational Checklist

  • No import cycles
  • Clear dependency direction (inward)
  • Interfaces defined at consumption boundaries
  • Domain free of infrastructure imports
  • Entry points isolated under cmd/
  • internal/ used intentionally
  • No god packages

Final Perspective

Package structure is not about folder aesthetics. It defines the shape of your system. In Go, simplicity is powerful — but only when boundaries are intentional. Architecture mistakes in package design compound over time. Design dependency direction carefully, and your system will scale cleanly.