Architecture Patterns — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build (or refactor), what success looks like, and a hint for the expected outcome. The focus throughout is on organization: where each file lives, what it imports, and how the dependency rule is enforced. For deeper conceptual exercises see
../../19-architecture-patterns/.
Easy¶
Task 1 — Recognise the pattern¶
You are given the following Go module trees. For each, name the architecture pattern in use.
A.
B.
cmd/api/main.go
internal/core/domain/
internal/core/port/
internal/core/service/
internal/adapter/primary/http/
internal/adapter/secondary/postgres/
C.
cmd/api/main.go
internal/entity/
internal/usecase/place_order.go
internal/usecase/cancel_order.go
internal/adapter/http/
internal/adapter/repository/
D.
cmd/api/main.go
internal/domain/model/
internal/domain/service/
internal/application/
internal/infrastructure/
Goal. Build pattern-recognition reflex. Answers at the end of this file.
Task 2 — Sketch a layered service¶
Design (on paper, no code) the folder tree for a Go service that exposes a single endpoint POST /tasks for creating a to-do task and stores tasks in Postgres. Use layered architecture. Every folder you list must have a one-sentence justification.
Goal. Practise the smallest pattern.
Task 3 — Spot the leak¶
Given this internal/domain/order.go, name the architectural violation:
package domain
import (
"database/sql"
"errors"
)
type Order struct {
ID string
Total int64
DB *sql.DB
}
func (o *Order) Validate() error {
if o.Total <= 0 { return errors.New("invalid total") }
return nil
}
Goal. Train your eye for "domain knows about infrastructure."
Task 4 — Write a boundary test¶
For a Go module rooted at github.com/me/shop, write a test under internal/core/domain/ that fails if the package imports database/sql, net/http, or anything under internal/adapter/. Use the go/build package or go list.
Goal. Make the architecture executable.
Task 5 — From flat package to layered¶
Take a real or mock Go file containing all of:
- HTTP handler
- SQL queries
- business validation
- struct definitions
…and split it into four packages — handler, service, domain, repo — preserving behaviour. Run the original tests; they must still pass.
Goal. Mechanical experience of "split by layer."
Medium¶
Task 6 — Convert layered to hexagonal¶
Starting from a small layered service (your own or one from Task 5):
- Create
internal/core/port/and move every interface declared inserviceinto it. - Rename
internal/repo/tointernal/adapter/secondary/postgres/. - Rename
internal/handler/tointernal/adapter/primary/http/. - Rename
internal/service/tointernal/core/service/. - Verify:
internal/core/does not import anyinternal/adapter/.... - Run the test suite.
Goal. Practise the most common pattern migration.
Task 7 — Add an in-memory adapter¶
After Task 6, add internal/adapter/secondary/memory/ containing a OrderRepo that implements the same port.OrderRepository interface using a map[OrderID]*Order. Write a unit test for the core service that uses the in-memory adapter and runs in <10 ms.
Goal. Demonstrate the testing payoff of hexagonal.
Task 8 — Wire two binaries that share a core¶
Take a hexagonal service and add a second binary cmd/worker/main.go that consumes from a fake "queue" (a channel in memory or a Kafka client) and calls the same core service. Both binaries must:
- Live under
cmd/. - Import the same
internal/core/packages. - Pull in different primary adapters.
Goal. Prove that hexagonal makes "the same logic, different drivers" cheap.
Task 9 — Identify dependency-direction violations¶
Given a Go module of your choice (or one constructed by an instructor), generate the import graph using:
…and identify every edge that violates a stated dependency rule (e.g., "core does not import adapter," "adapter does not import other adapter"). Produce a numbered list.
Goal. Familiarity with go list as an architectural inspection tool.
Task 10 — Add a depguard rule¶
In a Go module of your choice, add a .golangci.yml with a depguard rule that forbids internal/core/** from importing database/sql, net/http, or anything under internal/adapter/. Confirm the rule fires when you intentionally introduce a violation.
Goal. Encode an architectural rule as machine-checked configuration.
Task 11 — Convert layered to clean¶
Take the layered service from Task 5 and convert it to clean architecture:
- Rename
internal/domain/→internal/entity/. - Rename
internal/service/→internal/usecase/. - Make each use case a single file with one struct and one method (
ExecuteorHandle). - Move shared interfaces into
internal/usecase/ports.go. - Verify the dependency rule:
entityimports nothing from this module;usecaseimportsentityand its ownports;adapterimportsentityandusecase.
Goal. Practise the layered → clean migration.
Task 12 — Reorganise from layer-first to feature-first¶
Take a layered or hexagonal service with three or more entities (order, invoice, user). Refactor internal/ so that each feature is a sub-package containing all its layers:
internal/order/{handler,service,domain,repo}
internal/invoice/{handler,service,domain,repo}
internal/user/{handler,service,domain,repo}
Then refactor back to the hybrid:
internal/core/domain/{order,invoice,user}
internal/core/service/{order,invoice,user}
internal/adapter/primary/http/{order,invoice,user}
internal/adapter/secondary/postgres/{order,invoice,user}
Compare both layouts on:
- File count.
- Average files touched per typical feature change.
- Conceptual clarity for a new engineer.
Goal. Develop intuition for layer-vs-feature trade-offs.
Hard¶
Task 13 — Build a custom architecture analyzer¶
Using golang.org/x/tools/go/analysis, write a single-file analyzer that:
- Walks every Go file in the module.
- For each file under
internal/core/, fails if it imports any ofdatabase/sql,net/http, or any package underinternal/adapter/. - Outputs a
pass.Reportfwith the file, line, and the offending import.
Wire it into CI as a separate job that runs after go test.
Goal. Build an architectural rule that no off-the-shelf linter could express directly.
Task 14 — Migrate hexagonal to clean without a big bang¶
Given a hexagonal Go service (~3 KLOC), migrate it to clean architecture in a sequence of small PRs:
- PR 1: rename
internal/core/domain/→internal/entity/(alias-based to keep both paths working). - PR 2: rename
internal/core/service/→internal/usecase/(one use case per file). - PR 3: move ports from
internal/core/port/intointernal/usecase/ports.go. - PR 4: rename
internal/adapter/{primary,secondary}/→internal/adapter/. - PR 5: delete the aliases and the empty old directories.
Each PR must compile, pass tests, and be small enough to review in 30 minutes.
Goal. Practise the senior-level skill of refactoring without breaking the build.
Task 15 — Detect "fake hexagonal"¶
You inherit a Go service that claims to be hexagonal. Suspect it is hexagonal in name only (the actual logic lives in the HTTP handlers).
Write a small Go program that, for every package under internal/core/service/, prints the line count of non-test, non-comment Go code. Do the same for internal/adapter/primary/http/. If adapter/primary/http/ is more than 2× the size of core/service/, flag it as suspicious.
Goal. Build a measurement tool for "is the architecture working?"
Task 16 — Architect a modular monolith¶
Design (on paper) a Go module containing three bounded contexts: billing, catalog, fulfilment. Specify:
- Folder layout under
internal/. - The pattern each context uses (you may pick differently per context — justify each).
- The cross-context communication mechanism (event bus, exposed port, HTTP).
- The
cmd/binaries you would ship initially. - Which architectural rules are enforced by Go's
internal/rule, bydepguard, and by custom analyzers.
Produce a short ARCHITECTURE.md (≤ 200 lines).
Goal. Practise multi-context architectural reasoning.
Task 17 — Extract a context¶
Continuing from Task 16: extract billing from the monolith into its own Go module and its own service. The remaining contexts must continue to work, calling billing via HTTP/gRPC. List the steps in order, with the tests you would write at each step.
Goal. Prove that a well-architected monolith is extractable.
Task 18 — Quarterly architectural cleanup¶
Pick a Go codebase you are familiar with (work, OSS, your own). Spend 2 hours on the following audit:
- List every interface in
internal/core/port/(or equivalent). For each, count its implementations. Flag any with exactly one. - List every package. For each, identify whether it has changed in the last 6 months. Flag any with no changes.
- Run
gocycloon each function ininternal/core/. Flag anything with cyclomatic complexity > 10. - Read
cmd/<binary>/main.go. If it is over 200 lines, propose an extraction. - Produce a one-page summary: "what to delete, what to merge, what to keep."
Goal. Practise architectural subtraction — the senior skill that produces healthier codebases over time.
Hints / Expected Outcomes¶
Task 1 hints¶
A — layered. B — hexagonal. C — clean. D — onion.
Task 2 hint¶
cmd/tasks-api/main.go ← composition root
internal/
├── handler/task.go ← HTTP I/O, request decode, response encode
├── service/task.go ← validation + orchestration
├── domain/task.go ← Task struct, domain rules
└── repo/task.go ← Postgres queries
go.mod
A test for service mocks the repo; a test for handler mocks the service.
Task 3 hint¶
The Order struct holds *sql.DB. Domain entities must not carry persistence handles. Move *sql.DB out of the domain into the repository; pass it through repository methods, not through the domain object.
Task 4 hint¶
package domain_test
import (
"go/build"
"strings"
"testing"
)
func TestDomainHasNoForbiddenImports(t *testing.T) {
p, err := build.Default.Import("github.com/me/shop/internal/core/domain", "", 0)
if err != nil { t.Fatal(err) }
forbidden := []string{"database/sql", "net/http", "github.com/me/shop/internal/adapter"}
for _, imp := range p.Imports {
for _, bad := range forbidden {
if strings.HasPrefix(imp, bad) {
t.Errorf("forbidden import: %s", imp)
}
}
}
}
Task 6 hint¶
Use gopls rename or gofmt -r to update import paths in bulk. After renames, run go vet ./... and go test ./... to catch missed updates.
Task 7 hint¶
package memory
type OrderRepo struct{ data map[domain.OrderID]*domain.Order }
func New() *OrderRepo { return &OrderRepo{data: map[domain.OrderID]*domain.Order{}} }
func (r *OrderRepo) Save(o *domain.Order) error {
r.data[o.ID] = o; return nil
}
Task 8 hint¶
The two main.go files share the wiring of core/service. They differ only in which primary adapter they construct. The core constructor signature does not change.
Task 9 hint¶
go list -f '{{ .ImportPath }} -> {{ join .Imports " " }}' ./internal/... \
| grep -E "internal/core.*->.*internal/adapter"
If the grep finds anything, you have a violation.
Task 10 hint¶
linters:
enable: [depguard]
linters-settings:
depguard:
rules:
core:
files:
- "**/internal/core/**"
deny:
- pkg: "database/sql"
- pkg: "net/http"
- pkg: "github.com/me/shop/internal/adapter"
Task 11 hint¶
A clean use case is a small struct:
package usecase
type PlaceOrder struct{ Repo OrderRepository }
func (p *PlaceOrder) Execute(o *entity.Order) error {
if err := o.Validate(); err != nil { return err }
return p.Repo.Save(o)
}
One file per use case keeps the package readable as it grows.
Task 12 hint¶
Layer-first wins for cross-cutting changes (e.g., changing how all repos handle errors). Feature-first wins for vertical changes (adding one endpoint to one feature). The hybrid is best when the team grows past 4–5 engineers.
Task 13 hint¶
var Analyzer = &analysis.Analyzer{
Name: "noinfra",
Doc: "core/** must not import infrastructure packages",
Run: func(p *analysis.Pass) (interface{}, error) {
if !strings.Contains(p.Pkg.Path(), "/internal/core/") {
return nil, nil
}
for _, f := range p.Files {
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path == "database/sql" || path == "net/http" ||
strings.Contains(path, "/internal/adapter/") {
p.Reportf(imp.Pos(), "forbidden import in core: %s", path)
}
}
}
return nil, nil
},
}
Task 14 hint¶
Type aliases (type X = Y) make package renames zero-cost. After PR 1:
// internal/entity/order.go
package entity
import old "github.com/me/shop/internal/core/domain"
type Order = old.Order
This lets the rest of the code migrate import paths gradually. Delete the alias in PR 5.
Task 15 hint¶
A typical "fake hexagonal" service has a 1500-line adapter/primary/http/order.go and a 50-line core/service/order.go. The ratio of those two line counts is a fast sniff test.
Task 16 hint¶
A reasonable starting layout:
internal/
├── billing/
│ ├── core/{domain,port,service}
│ └── adapter/...
├── catalog/
│ └── ... (layered if simple)
├── fulfilment/
│ └── ... (hexagonal if complex)
└── shared/
└── events/ ← cross-context event types (small, stable)
cmd/
├── api/main.go
├── worker-billing/main.go
└── worker-fulfilment/main.go
Cross-context calls go through shared/events/ (an in-process bus or a wrapper around Kafka). Direct imports of another context's internal/ are forbidden; enforce with a custom analyzer.
Task 17 hint¶
Step-by-step:
- Define an HTTP/gRPC contract for
billing's public operations. - Replace in-process calls in other contexts with calls to a
BillingClientinterface. - Implement
BillingClientfirst as a direct struct delegating to the in-process billing core; then as an HTTP client. - Add a feature flag that switches between the two implementations at startup.
- Move
billing/to a new modulegithub.com/me/billing. Update imports. - Run the system with the HTTP client. When stable, delete the in-process delegate.
Each step ships independently; rollback at any point is one PR.
Task 18 hint¶
Aim to delete something. If the audit produces no deletions, you missed candidates; look harder. The healthiest senior signal is the willingness to remove a layer that was added "just in case."