Every time you add a method to a service, you expand the dependency surface for everyone who uses it. A TaskService with 20 methods means every consumer depends on all 20 — even if they only need one. As your application grows, this coupling compounds. Then the circular dependencies appear.
Services Grow, Surfaces Expand
The traditional service pattern starts clean:
type TaskService struct {
repo *TaskRepository
}
func (s *TaskService) Create(title string) (*Task, error) { ... }Then requirements arrive. Tasks need projects. Tasks need areas. Tasks need tags, recurrence, completion logic. The service grows:
type TaskService struct {
repo *TaskRepository
projectService ProjectService
areaService AreaService
}
func (s *TaskService) Create(...) error { ... }
func (s *TaskService) Complete(...) error { ... }
func (s *TaskService) SetProject(...) error { ... }
func (s *TaskService) SetArea(...) error { ... }
func (s *TaskService) AddTag(...) error { ... }
func (s *TaskService) SetRecurrence(...) error { ... }
// ... 15 more methodsEach new method potentially adds cross-domain dependencies. Every consumer of TaskService now implicitly depends on ProjectService and AreaService — even if they only call Create.
When Surfaces Collide
This gets worse when bounded contexts need each other. Consider:
- Creating a task requires validating the project exists
- Completing a project requires completing all its tasks
The traditional approach:
type TaskService struct {
projectService ProjectService // Need to validate project
}
type ProjectService struct {
taskService TaskService // Need to complete child tasks
}Circular import. The compiler refuses.
The frustrating part: CreateTask only needs project lookup. CompleteProject only needs task completion. These operations don’t overlap. But because both are bundled into monolithic services, the entire service becomes the dependency unit.
Use Case Architecture
The fix: make each operation its own struct with exactly the dependencies it needs.
type ProjectLookup interface {
Execute(name string) (*Task, error)
}
type CreateTask struct {
Repo *TaskRepository
ProjectLookup ProjectLookup
}
func (c *CreateTask) Execute(title string, opts *CreateOptions) (*Task, error) {
if opts.ProjectName != "" {
p, err := c.ProjectLookup.Execute(opts.ProjectName)
if err != nil {
return nil, err
}
// assign task to project
}
// ... create the task
}The interface is defined by the consumer, not the project domain. CreateTask doesn’t depend on everything projects can do — just on “something that can look up a project by name.”
Solving Circular Dependencies
With use case architecture, the circular dependency disappears:
CreateTaskneedsProjectLookup(interface) → satisfied byGetProjectByNameCompleteProjectneedsTaskCompleter(interface) → satisfied byCompleteTasks
No circular imports because:
- Neither domain knows the other’s full API
- Interfaces are consumer-defined, not provider-defined
- Each use case declares exactly what it needs
The dependency graph stays a DAG. Always.
The Wiring
Dependencies resolve in a neutral location—a composition root:
func New(db *database.DB) *App {
taskRepo := task.NewRepository(db)
areaRepo := area.NewRepository(db)
getAreaByName := &areausecases.GetAreaByName{Repo: areaRepo}
getProjectByName := &taskusecases.GetProjectByName{Repo: taskRepo}
createTask := &taskusecases.CreateTask{
Repo: taskRepo,
ProjectLookup: getProjectByName,
AreaLookup: getAreaByName,
}
return &App{
CreateTask: createTask,
GetProjectByName: getProjectByName,
// ... other use cases
}
}Each use case gets exactly what it needs. GetProjectByName satisfies the ProjectLookup interface that CreateTask defined. No service layer coordinates everything—use cases compose directly.
Trade-offs
This pattern has costs:
More files. One file per operation. A domain with 10 operations has 10 use case files instead of one service file.
Verbose wiring. The composition root grows as use cases multiply. In a real application, app.go might wire 30+ use cases.
Requires discipline. Use cases must stay focused. The temptation to add “just one more dependency” defeats the purpose.
The benefits:
Easy to test. Mock only the interfaces a use case declares. CreateTask tests mock ProjectLookup and AreaLookup—nothing else.
Easy to understand. Open a file, see one operation. No scrolling through a 500-line service to find the method you care about.
Changes stay isolated. Modifying CreateTask doesn’t affect CompleteTasks. Different operations can evolve independently.
No circular dependency risk. The architecture makes cycles impossible by construction.
The Core Insight
The unit of dependency should match the unit of operation. Services bundle unrelated operations, creating artificial coupling between consumers and producers. Use cases keep dependencies minimal and directional—each operation depends only on what it actually needs.
As your application grows, the dependency surface stays proportional to individual operations, not to the sum of everything each domain can do.