JavaRush /Courses /Go SELF /Layers: domain → app/usecases → adapters

Layers: domain → app/usecases → adapters

Go SELF
Level 28, Lesson 0
Available

1. Layer diagram and dependency direction

When you are just learning, you want to do it like this: main.go with 400 lines, everything next to each other, everything “at hand”. At some point it even works — roughly until the first serious change, when you need to add a new feature and you do not understand what will break if you move one line. It is like fixing a bicycle where the handlebars are screwed to the pedals: technically it rides, but only straight and only as long as you do not blink.

Layers help break that weird mechanism. They give you a simple idea: code is grouped by role, and dependencies are directed “from the inside out”. And the most important part: in Go the compiler directly participates in discipline — through imports. import is not just “include functions”; it is consent to depend on someone else’s API at compile time.

Minimal layer map: domain → app/usecases → adapters

If you have heard the word “architecture” and imagine a department chair where people in black turtlenecks argue about UML to the bitter end — you are not alone. But today we have an architecture version for regular mortals: three layers that you can explain on your fingers and maintain without ritual dances.

Let’s agree on terms (they are very practical):

Layer What lives there What must not be there
domain (domain/model) domain data and rules: entities, invariants input/output, files, HTTP, fmt.Println “just in case”
app / usecases (scenarios) application-level operations: “create a task”, “find a task”, “mark as done” specific databases, specific CLI/HTTP, global state
adapters (adapters) concrete implementations: memory, file, database, CLI glue, HTTP glue business rules of “how it should be” (they are not here)

Key idea: the same use cases (app/usecases) can be plugged into CLI, HTTP, and tests, because use cases talk to the outside world through interfaces, not through concrete implementation packages. But today we are talking about the idea of layers and the direction of imports; we will keep dependency injection magic to a minimum, without extra complexity.

Dependency direction: the outer knows about the inner

Here is a phrase worth keeping in your head like a road sign: dependencies point from the outer to the inner. That is, CLI/HTTP/storage know about app and domain; use cases know about domain; and domain does not know about anyone at all — it is like a wise cat: it simply exists and follows its rules.

Why does this matter? Because otherwise you get an “import spaghetti graph”: one package imports the second, the second imports the third, the third suddenly imports the first — and the compiler tells you a strict “no”. And the compiler is right: cyclic imports almost always mean you mixed up responsibilities.

It is convenient to picture it like this:

flowchart LR
    domain[domain: entities and rules]
    app[app/usecases: scenarios]
    adapters[adapters: storage/cli/http]

    adapters --> app
    app --> domain

If your arrow suddenly goes backward (for example, domain imports adapters) — that is not “it happens”; it is a signal: you are trying to shove infrastructure details into a place that should contain rules only.

Import as a compile-time dependency

In Go, an import is not just “give me a function”. When you import a package, you start depending on its exported API. And that is not philosophy — it is a very practical contract that is hard to change without consequences.

We are not writing the standard library, of course, but the principle is the same: the more you export and the more packages import it, the harder it is to change the code.

That leads to an important connection to the layer topic: layers reduce the number of “dangerous” dependencies. For example, if the CLI layer depends on app, then changing the CLI should not force you to rewrite the domain. But if the domain suddenly starts depending on the CLI — congratulations, you have invented “business rules depend on the terminal prompt text”. It sounds funny, but in real code such things appear quietly.

3. import path and module path in your own packages

Now there will be a small terminology frame — no commands and no deep dive into Go Modules. It is just important to understand why importing your own packages looks “long”.

In Go, there is a string you write in import "...". That is the import path. For external libraries it usually looks like a URL: domain + path, for example "golang.org/x/oauth2". This import style has been used in the Go ecosystem for many years.

For your own packages, the import path usually starts with the module path — the string specified in go.mod after the word module. We are not running commands and we are not discussing version management, but let’s fix the term: the module path is the prefix from which imports of your packages are built.

Let’s imagine the project’s module path is:

example.com/tasker

Then your package imports will look roughly like this:

import "example.com/tasker/domain"
import "example.com/tasker/app"
import "example.com/tasker/adapters/memstore"

And that is normal. Yes, it is long. But it is unambiguous: in a large project there will not be “two different domain packages from different places”.

4. Example: the Tasker mini-app

Directory structure

So that layers are not just an abstract lecture, let’s assemble a small app that you can extend later: a task manager tasker. Today we will lay down only the skeleton and minimal types to see how layers are reflected in the file structure and imports.

Here is the project “skeleton” (as an idea):

tasker/
  go.mod                  (module example.com/tasker)
  main.go
  domain/
    task.go
  app/
    add_task.go
  adapters/
    memstore/
      store.go

Important point: this structure is not a religion. It is just a convenient way to make the role visible from the path. We could name app as usecases; we could name domain as model; we could name adapters as infra. Today we fix the idea, not the one-and-only correct naming.

The domain layer: the Task entity and the rule “title is not empty”

Domain types are what you want to have even in a world with no terminal, no server, no files, and no database. If tomorrow your CLI disappears, the domain should still describe your problem space. That is why in the domain we keep the Task struct and a minimal rule: the title must not be empty.

File domain/task.go:

package domain

import "errors"

var ErrEmptyTitle = errors.New("empty title")

type Task struct {
	ID    int
	Title string
	Done  bool
}

func NewTask(id int, title string) (Task, error) {
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	return Task{ID: id, Title: title}, nil
}

Notice a few things. We do not print anything to the console. We do not read files. We return an error because the domain is not the place where you decide exactly how to report a problem to the user. And yes, ErrEmptyTitle is exported: it is part of the domain meaning, and it can be used to make decisions at higher levels.

The app/usecases layer: a task-adding use case and the dependency interface

A use case is “what the application does”, but without the details of “how it stores data” and “which interface the user clicks”. Here we describe the operation of adding a task. And we need a dependency: a place to store data. But we do not want to drag a concrete implementation (for example, in-memory or a file) directly into the use case.

That is why the use case declares an interface — the minimal contract it needs.

File app/add_task.go:

package app

import (
	"context"
	"fmt"

	"example.com/tasker/domain"
)

type TaskStore interface {
	NextID(ctx context.Context) (int, error)
	Save(ctx context.Context, t domain.Task) error
}

func AddTask(ctx context.Context, store TaskStore, title string) (domain.Task, error) {
	id, err := store.NextID(ctx)
	if err != nil {
		return domain.Task{}, fmt.Errorf("next id: %w", err)
	}

	t, err := domain.NewTask(id, title)
	if err != nil {
		return domain.Task{}, err
	}

	if err := store.Save(ctx, t); err != nil {
		return domain.Task{}, fmt.Errorf("save task: %w", err)
	}
	return t, nil
}

Here we do an important architectural thing: app imports domain, but does not import any adapters. This allows you to plug in any TaskStore implementation later. And yes, context.Context for now looks like “an extra parameter”, but it is an investment: the use case starts looking like a proper application operation.

The adapters layer: an in-memory store for the required interface

An adapter is where details appear. For example, storage in memory. It may be imperfect and not durable (everything disappears on restart), but it is simple. And most importantly: it is not part of the domain and not part of the use case. It is just one way to satisfy the interface.

File adapters/memstore/store.go:

package memstore

import (
	"context"

	"example.com/tasker/domain"
)

type Store struct {
	next int
	data map[int]domain.Task
}

func New() *Store {
	return &Store{next: 1, data: make(map[int]domain.Task)}
}

Let’s continue with the methods the use case needs:

func (s *Store) NextID(ctx context.Context) (int, error) {
	id := s.next
	s.next++
	return id, nil
}

func (s *Store) Save(ctx context.Context, t domain.Task) error {
	s.data[t.ID] = t
	return nil
}

Notice: the memstore package imports domain because it stores the domain entity. But it does not import app. This is also part of the dependency direction: the adapter implements the contract, but the contract lives “above” (on the consumer side). For today, it is enough to fix this as an idea, without subtle arguments about “where the interface should live”.

main as the place where layers meet

When you first see layers, a question arises: “so where do I assemble all of this?”. The answer is down-to-earth: in main. The application entry point is the place where it is acceptable to connect details. This is not an “architectural sin”; it is the job of main: choose implementations and run use cases.

File main.go:

package main

import (
	"context"
	"fmt"

	"example.com/tasker/adapters/memstore"
	"example.com/tasker/app"
)

func main() {
	store := memstore.New()

	t, err := app.AddTask(context.Background(), store, "read Go book")
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Printf("added: #%d %s\n", t.ID, t.Title) // added: #1 read Go book
}

Here we saw what we built layers for: main imports the adapter and app, while app does not know what memstore is at all. If later you want to store tasks in a file or a database, the use case logic should not need rewriting.

How to read a project via imports

When a project grows, you rarely read all the code end to end. More often you read the directory structure and imports. It is almost like looking at a metro map: you care where you can get without transfers, and where transfers are forbidden.

A useful self-check for our example:

flowchart TD
    main[main] --> appPkg[app]
    main --> mem[memstore]
    appPkg --> domainPkg[domain]
    mem --> domainPkg

If you see an arrow domain -> app or domain -> memstore, it means the domain “leaked” infrastructure. If you see app -> memstore, it means the use case started depending on specifics and lost its meaning as portable logic. If you see a cycle — the compiler will stop you, and it will be the best “gift” of your day.

5. Typical mistakes when introducing layers

Mistake #1: doing “layers for layers’ sake” without a real reason.
Sometimes people read about architecture and start splitting even a 50-line program into ten packages. As a result, you do not gain flexibility, but you gain navigation complexity. Layers are useful when an application has at least two different “outside worlds” (for example, CLI today, HTTP tomorrow) or when business logic starts repeating and you want to keep it in one place.

Mistake #2: the domain starts doing input/output.
The most common way to “break” the layer idea is to add something in domain like fmt.Println("task created") or reading from a file “to make it convenient”. The domain should be boring: structs, methods, rule checks, errors. As soon as I/O appears in the domain, it becomes environment-dependent and stops being a portable core.

Mistake #3: a use case imports a concrete adapter implementation.
It looks innocent: “well I need to save a task, I will just import memstore and that’s it”. But that is exactly the point where the use case stops being a use case and becomes “an application permanently glued to memory”. The correct path is to depend on an interface and receive the implementation from the outside, even if for now it looks a bit more verbose.

Mistake #4: confusing package name and import path.
Beginners sometimes think a package must be named the same as the last part of the import path. Usually that is how it is done, but it is not the same thing. The import path is the “address” where the package is located, and the package name is how you refer to it in code. Because of this, strange imports and aliases sometimes appear. Do not panic: it is a normal part of a project growing up.

Mistake #5: trying to solve all architectural questions at once.
You want everything immediately: a perfect domain, beautiful use cases, universal adapters, tests, a DI container, and for the compiler to applaud standing up. In practice, it is more useful to move in small steps: first separate the domain, then the use cases, then the adapters. Layers are not a goal; they are a way to keep complexity on a leash before it eats your brain.

1
Task
Go SELF, level 28, lesson 0
Locked
User passport
User passport
1
Task
Go SELF, level 28, lesson 0
Locked
Adding a todo
Adding a todo
1
Task
Go SELF, level 28, lesson 0
Locked
Pocket Library
Pocket Library
1
Task
Go SELF, level 28, lesson 0
Locked
Console notes
Console notes
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION