Guide: application architecture
tango newproject/tango newapp (see project structure) always start you at the same small shape. This guide covers what comes after: how to evolve a growing tanGO app's structure without guessing, and when not to bother.
There is no single "correct" tanGO project layout. There are three recommended shapes, forming an escalation path — move to the next one only once the app actually earns it. A small app that stays small for its whole life is the normal, unremarkable outcome, not a project that "hasn't gotten around to" the next shape yet.
Small app
Use this when the app is mostly CRUD or a small site — the shape tango newapp already gives you:
models.go
views.go
urls.go
admin.go
migrations/
templates/
A model struct here does double duty: it's both the persistence record (db.Store reads and writes it directly) and the closest thing the app has to a domain type. Views call db.Store directly. There's no separate service layer, no ports/, nothing to wire — see project structure for what a freshly scaffolded app already contains, and the api-with-admin example for a small app in full.
Medium app
Use this once views are starting to hold application decisions — validation that spans more than one field, multi-step writes, logic that would otherwise get copy-pasted across two views:
models.go
services/
repositories/ (or store/)
views.go
urls.go
admin.go
migrations/
templates/
services/ holds that logic so views stay thin translators of HTTP into a call. repositories//store/ wraps db.Store once direct calls scattered across views get noisy. models.go still serves as both persistence metadata and domain data — a Medium app has no ports/ and no separate domain package; that split is what marks the next step, not this one.
Hexagonal app
Use this once the app has real business rules, multiple interfaces (HTML and JSON, or a CLI alongside HTTP), reusable domain logic, or long-term maintenance pressure that makes the Medium shape's direct services/ → repositories/ coupling start to hurt:
domain/ # entities, value objects, pure rules
ports/ # interfaces the application layer depends on
services/ # use cases and orchestration
adapters/db/ # SQL or tango db.Store persistence adapters
adapters/http/ # tango views, binders, render helpers, JSON responses
adapters/admin/ # tango admin registration and admin-specific widgets/options
urls.go # URL contribution wiring
migrations/
This is the one shape where persistence-facing structs and domain objects become separate types: persistence models move into adapters/db/, domain objects stay in domain/ with no db/model tags and no awareness that a database exists at all. Admin registration moves into adapters/admin/ too, for the same reason — it's a framework-facing adapter, not a privileged layer that gets to stay at app root once everything else has moved out.
Design principles
These hold across all three shapes, not just Hexagonal:
- Views translate HTTP into use cases; they don't own business rules.
- Admin configuration is an adapter, not a privileged domain layer.
domain/andservices/never depend ontango.Context, Chi, templates, or admin packages — those are framework-facing concerns that belong inadapters/.- Persistence sits behind a small, project-owned interface once direct
db.Storecalls scattered through the codebase get noisy — arepositories//store/package at the Medium tier, aports/interface implemented by anadapters/db/type at the Hexagonal tier.
Snippets
These are illustrative Go, written to the same APIs used elsewhere in this repo — not a checked-in, CI-built example module (this milestone is documentation only; see limitations for what's actually shipped).
A view calling a service:
// adapters/http/views.go
func checkoutBook(svc *services.CheckoutService) tango.View {
return func(ctx *tango.Context) error {
var req checkoutRequest
if err := ctx.Bind(&req); err != nil {
return err
}
order, err := svc.Checkout(ctx.Context(), req.BookID, req.CustomerID)
if err != nil {
return err
}
return ctx.JSON(201, order)
}
}
A service depending on a port, not a concrete adapter:
// ports/books.go
type BookRepository interface {
FindByID(ctx context.Context, id int64) (domain.Book, error)
}
// services/checkout.go
type CheckoutService struct {
books ports.BookRepository
}
func (s *CheckoutService) Checkout(ctx context.Context, bookID, customerID int64) (domain.Order, error) {
book, err := s.books.FindByID(ctx, bookID)
if err != nil {
return domain.Order{}, err
}
return domain.NewOrder(book, customerID), nil
}
A DB adapter implementing that port:
// adapters/db/books.go
type bookRepository struct {
store *db.Store
meta model.ModelMeta
}
func (r *bookRepository) FindByID(ctx context.Context, id int64) (domain.Book, error) {
var record BookRecord
if err := r.store.Get(ctx, r.meta, id, &record); err != nil {
return domain.Book{}, err
}
return record.ToDomain(), nil
}
An admin file staying thin:
// adapters/admin/books.go
func RegisterBooks(registry *tango.Registry) error {
return registry.Admin().Register(db.BookRecord{}, admin.Options{
ListDisplay: []string{"Title", "AuthorID"},
})
}
When to stop
tanGO supports the Hexagonal shape; it never requires it. Most tanGO apps should stay Small. Moving to Medium or Hexagonal without the growth signals above just adds indirection with nothing behind it — a ports/ package with exactly one implementation and no second interface to decouple from is ceremony, not architecture.
See also
- Project structure — what
tango newproject/tango newappactually generate. - Reusable apps — packaging an app as its own importable Go package, a separate axis from the shape it's structured in internally.
docs/agents/project-shape.md— the compact, agent-facing version of this guide's escalation rules.