Designing Swappable Go Applications: From MVC to Ports & Adapters
Go makes it very easy to build software.
Sometimes almost too easy.
You can start with a main.go, add an HTTP handler, connect a database, sprinkle some business logic between them, and have a working backend before lunch.
The problems usually arrive later.
Maybe PostgreSQL needs to become DynamoDB.
Maybe the REST API needs a CLI.
Maybe the same business operation now needs to be triggered by Kafka.
Maybe you want to test your application without starting Docker, PostgreSQL, Redis, and half of AWS.
Or maybe the application simply became large enough that changing one feature unexpectedly breaks three unrelated ones.
At that point, the question stops being:
Where should I put this Go file?
The more important question becomes:
Which parts of the system should be allowed to depend on which other parts?
That is really what software architecture is about.
In this article, I want to work from simple Go programs through MVC and layered architectures, eventually arriving at Ports and Adapters also known as Hexagonal Architecture and some of the ideas commonly associated with Clean Architecture.
More importantly, we will look at how these ideas translate into idiomatic Go, because copying an enterprise Java architecture package-for-package usually gives us a lot of ceremony without much benefit.
The goal is not to build the most architecturally impressive directory tree.
The goal is to build software where the things that are likely to change are easy to replace.
Start With the Most Important Rule: Don't Architect Too Early
Before talking about Clean Architecture, repositories, ports or adapters, there is an important rule:
A small Go application should look like a small Go application.
The official Go module documentation explicitly shows perfectly valid applications consisting of only a handful of files in one package. As applications grow, it recommends introducing supporting packages and, particularly for server applications, using internal/ and optionally cmd/.
A small CLI might genuinely need nothing more than:
mytool/
├── go.mod
├── main.go
├── config.go
└── client.goThere is absolutely no reason to immediately transform that into:
domain/
application/
ports/
adapters/
repositories/
controllers/
services/
factories/
infrastructure/Architecture has a cost.
Every interface is another abstraction someone has to understand.
Every package introduces another boundary.
Every mapping layer introduces another transformation.
The architecture becomes valuable only when those boundaries protect you from changes that are actually likely to happen.
So my preferred rule is:
Start concrete. Add boundaries where change appears.
What Are We Actually Trying to Make Swappable?
"Loose coupling" can become a vague architectural buzzword.
It becomes much more useful when we talk about specific things that might change.
Imagine an application that creates orders.
Today:
HTTP
↓
Order Logic
↓
PostgreSQLSix months later, the requirements might look like this:
REST API ──────┐
CLI ───────────┤
Kafka Consumer ┼──→ Order Logic ──→ PostgreSQL
Cron Job ──────┘Then perhaps PostgreSQL is not the only destination:
┌──→ PostgreSQL
Order Logic ────────┼──→ Redis
├──→ Stripe
└──→ KafkaNotice that there are two kinds of external systems.
Things that drive our application:
HTTP
CLI
Kafka consumer
Cron job
TestsAnd things our application drives:
Database
Cache
Payment gateway
Message broker
Email provider
External APIThat distinction is at the heart of Ports and Adapters.
Alistair Cockburn's original Hexagonal Architecture description was specifically intended to let an application run independently of its UI and database, allowing it to be driven by users, automated tests, scripts, or other programs while connecting to external technologies through adapters.
The important part is not the hexagon.
The important part is the boundary.
Where MVC Fits
One of the first architectural patterns many developers encounter is MVC:
Model
View
ControllerFor traditional web applications, MVC is extremely useful.
A controller receives input.
The model represents application state or domain data.
The view presents the result.
A simplified Go application might look like:
HTTP Request
↓
Controller
↓
Model
↓
DatabaseFor a server-rendered web application this can be completely reasonable.
The problem begins when MVC becomes the architecture for the entire system.
The word Model tends to absorb everything:
models/
user.go
order.go
payment.go
database.go
validation.go
email.goControllers then begin calling database code directly.
Business rules creep into handlers.
HTTP request types travel deep into application logic.
Database structs become API responses.
Eventually the application becomes organized around its delivery framework rather than around what the application actually does.
MVC therefore remains useful, but I prefer treating it as a pattern at the transport boundary rather than necessarily making it the architectural center of a large Go backend.
Your HTTP adapter can absolutely contain controllers.
The rest of your application does not need to know that controllers exist.
A Better Intermediate Step: Handler → Service → Repository
Before going all the way to Ports and Adapters, many Go applications benefit from a simple layered structure:
HTTP Handler
↓
Service
↓
Repository
↓
DatabaseFor example:
type OrderService struct {
repo *PostgresOrderRepository
}
func (s *OrderService) CreateOrder(
ctx context.Context,
customerID string,
) error {
// Business logic...
return s.repo.Create(ctx, customerID)
}This is already much better than placing SQL directly inside the HTTP handler.
The handler deals with HTTP.
The service deals with the application operation.
The repository deals with persistence.
But there is still an architectural problem:
repo *PostgresOrderRepositoryThe higher-level application logic depends directly on PostgreSQL.
The source-code dependency points toward infrastructure.
Application ──────→ PostgreSQLThat means PostgreSQL is not really a replaceable detail.
It is part of the application's dependency graph.
Ports and Adapters flips that dependency.
Ports and Adapters: The Important Idea
Instead of this:
Application ──────→ PostgreSQLwe want this:
interface
Application ─────────○
↑
│
PostgreSQLThe application defines what it needs.
Infrastructure satisfies that requirement.
In Go, that maps beautifully onto interfaces because interface implementation is implicit.
Consider:
type OrderRepository interface {
Save(ctx context.Context, order Order) error
}Our application service depends on that capability:
type CreateOrderService struct {
orders OrderRepository
}
func NewCreateOrderService(
orders OrderRepository,
) *CreateOrderService {
return &CreateOrderService{
orders: orders,
}
}And the application logic becomes:
func (s *CreateOrderService) Create(
ctx context.Context,
customerID string,
) error {
order := NewOrder(customerID)
return s.orders.Save(ctx, order)
}Notice what is missing.
There is no PostgreSQL import.
There is no SQL.
There is no HTTP framework.
There is no Redis client.
There is no AWS SDK.
The application simply knows:
I need something capable of saving an order.
That is the port.
PostgreSQL can then provide an adapter:
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(
ctx context.Context,
order Order,
) error {
_, err := r.db.ExecContext(
ctx,
`
INSERT INTO orders (id, customer_id)
VALUES ($1, $2)
`,
order.ID,
order.CustomerID,
)
return err
}Nothing explicitly says:
implements OrderRepositoryGo does not need that.
The method set satisfies the interface automatically.
Now we can create another adapter:
type MemoryOrderRepository struct {
orders map[string]Order
}
func (r *MemoryOrderRepository) Save(
ctx context.Context,
order Order,
) error {
r.orders[order.ID] = order
return nil
}The application does not change.
That is the architecture doing useful work.
Put Interfaces Where They Are Consumed
There is a subtle Go-specific point here.
Developers coming from languages with explicit interface implementation often create structures such as:
interfaces/
order_repository.go
repositories/
postgres.goThat is usually unnecessary.
Go's own code-review guidance recommends that interfaces generally live in the package that uses them rather than in the package implementing them, and warns against defining interfaces prematurely merely for mocking.
This makes dependency inversion particularly natural.
Our application package says:
package order
type Repository interface {
Save(context.Context, Order) error
}PostgreSQL simply provides a compatible implementation.
package postgres
type OrderRepository struct {
db *sql.DB
}The infrastructure should not dictate what the application needs.
The application should define the smallest capability necessary to perform its job.
The Dependency Rule
Clean Architecture generalizes this idea.
The most important concept is not the famous circular diagram.
It is the direction of source-code dependencies.
Higher-level business policy should not depend on lower-level implementation details.
Robert C. Martin describes the architectural rule as dependencies pointing inward toward higher-level policy. Cockburn's Ports and Adapters reaches a very similar destination by separating the inside of the application from external technologies through ports.
For a Go backend, I like thinking about it like this:
┌─────────────────────────────────────┐
│ Infrastructure │
│ │
│ PostgreSQL Redis Stripe Kafka │
│ ↓ implements │
│ ┌─────────────────────────────┐ │
│ │ Application │ │
│ │ │ │
│ │ CreateOrder │ │
│ │ CancelOrder │ │
│ │ ChargeCustomer │ │
│ │ ↓ │ │
│ │ ┌──────────┐ │ │
│ │ │ Domain │ │ │
│ │ │ Order │ │ │
│ │ │ Payment │ │ │
│ │ └──────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘The outer layers can know about the inner layers.
The inner layers should not know about the outer layers.
The domain should not know PostgreSQL exists.
The application should not know whether it was invoked through HTTP, CLI or Kafka.
That is what gives us replaceability.
A Practical Go Structure
For a reasonably serious backend, I might start with something like:
orders/
├── cmd/
│ ├── api/
│ │ └── main.go
│ └── worker/
│ └── main.go
│
├── internal/
│ ├── order/
│ │ ├── order.go
│ │ ├── create.go
│ │ └── repository.go
│ │
│ ├── httpapi/
│ │ └── orders.go
│ │
│ ├── postgres/
│ │ └── orders.go
│ │
│ └── messaging/
│ └── orders.go
│
├── migrations/
├── go.mod
└── go.sumThe official Go documentation specifically recommends internal/ for server implementation packages and describes cmd/ as a useful convention when a repository contains multiple commands. internal also has actual language-tooling enforcement: packages beneath it cannot be imported from outside the permitted parent tree.
But notice what I have not done.
I have not created:
controllers/
services/
repositories/
models/
interfaces/
utils/
helpers/as global buckets.
Why?
Because once an application grows, organizing everything purely by technical layer often spreads one feature across the entire repository.
Adding an order feature might require changing:
controllers/order.go
services/order.go
repositories/order.go
models/order.go
validators/order.goInstead, I prefer the codebase to reveal the concepts the application actually deals with.
Go's own package guidance similarly discourages meaningless packages such as util, common, misc, api, types, and giant interface collections, because package names should communicate a useful abstraction.
Domain Packages Should Describe the Business
Suppose we have:
internal/
order/
customer/
billing/That immediately tells us what the system does.
Compare that to:
internal/
controller/
service/
repository/
model/That tells us which programming patterns the developers used.
It tells us almost nothing about the application.
This idea is sometimes called Screaming Architecture: looking at a project's high-level structure should reveal what the system does rather than primarily revealing its frameworks.
For Go, this often maps naturally onto packages because packages are already strong encapsulation boundaries.
HTTP Should Be an Adapter
Now suppose we expose the order operation over HTTP.
The HTTP layer should convert HTTP concepts into application concepts.
type OrderHandler struct {
create *order.CreateService
}
func (h *OrderHandler) Create(
w http.ResponseWriter,
r *http.Request,
) {
var request struct {
CustomerID string `json:"customer_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
err := h.create.Create(
r.Context(),
request.CustomerID,
)
if err != nil {
http.Error(w, "unable to create order", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}Notice where http.Request stops.
At the adapter boundary.
We do not write:
func (s *CreateOrderService) Create(
r *http.Request,
) errorbecause now our application service is coupled to HTTP.
A Kafka consumer cannot naturally call it.
A CLI cannot naturally call it.
A unit test now needs to construct an HTTP request to execute business logic.
Instead:
HTTP Request
↓
HTTP Adapter
↓
Application Input
↓
Use CaseThe adapter translates one world into another.
A CLI Is Just Another Adapter
This is where the architecture starts paying off.
Suppose we later want:
orders create --customer=123The CLI implementation can call exactly the same application service:
func runCreate(
ctx context.Context,
create *order.CreateService,
customerID string,
) error {
return create.Create(ctx, customerID)
}Now we have:
HTTP ─────┐
│
CLI ──────┼──→ CreateOrder ──→ OrderRepository
│
Kafka ────┘HTTP does not contain the business operation.
The CLI does not contain the business operation.
Kafka does not contain the business operation.
They are merely different ways of entering the application.
This is one of my favourite consequences of Ports and Adapters.
A CLI and a REST API stop being different applications.
They become different adapters around the same application.
main Is the Composition Root
Eventually, something has to choose the concrete implementations.
That is where main becomes important.
For example:
func main() {
db, err := sql.Open(
"postgres",
os.Getenv("DATABASE_URL"),
)
if err != nil {
log.Fatal(err)
}
orderRepository :=
postgres.NewOrderRepository(db)
createOrder :=
order.NewCreateService(orderRepository)
handler :=
httpapi.NewOrderHandler(createOrder)
server :=
http.Server{
Addr: ":8080",
Handler: httpapi.NewRouter(handler),
}
log.Fatal(server.ListenAndServe())
}This is dependency injection.
No framework required.
We explicitly construct the object graph.
Postgres Adapter
↓
CreateOrder Service
↓
HTTP Adapter
↓
HTTP ServerI generally like keeping this wiring close to the executable because main already knows about the outside world.
It knows which database we selected.
It knows configuration.
It knows which server we are starting.
The business core does not.
Swapping PostgreSQL for Something Else
Suppose the repository currently uses PostgreSQL:
repo := postgres.NewOrderRepository(db)Later we decide to use DynamoDB:
repo := dynamodb.NewOrderRepository(client)As long as both satisfy:
type Repository interface {
Save(context.Context, Order) error
}the application service does not change.
service := order.NewCreateService(repo)That is real replaceability.
But there is an important warning here.
You should not create an interface for every dependency merely because it might change someday.
If the application only uses PostgreSQL and there is no meaningful boundary requiring abstraction, the concrete implementation may be perfectly fine.
Interfaces become valuable when they represent a capability the consumer genuinely needs.
Small Interfaces Beat "Enterprise Interfaces"
Suppose your application only needs:
type OrderFinder interface {
Find(
context.Context,
string,
) (Order, error)
}Do not automatically create:
type OrderRepository interface {
Create(...)
Update(...)
Delete(...)
Find(...)
FindAll(...)
FindByCustomer(...)
Count(...)
Exists(...)
}just because the database can perform those operations.
The interface should describe the needs of the consumer, not the full capability of the implementation.
This is one reason Go interfaces work particularly well for architectural boundaries.
They can be tiny.
Sometimes a single method is enough.
Effective Go itself notes that one- or two-method interfaces are common throughout Go.
Design Patterns in Go Should Usually Become Smaller
Traditional design-pattern books often come from object-oriented languages where inheritance, explicit interface declarations, factories and class hierarchies are central tools.
Go has different strengths.
Composition.
Functions as values.
Small interfaces.
Struct embedding.
Implicit interface satisfaction.
Because of that, many classical patterns become much simpler.
Consider the Strategy Pattern.
Instead of creating an elaborate hierarchy:
PaymentStrategy
CreditCardStrategy
PayPalStrategy
CryptoStrategyGo may need only:
type PaymentProcessor interface {
Charge(
context.Context,
Money,
) error
}Or perhaps not even an interface:
type ChargeFunc func(
context.Context,
Money,
) errorThen:
type Checkout struct {
charge ChargeFunc
}The pattern still exists conceptually.
The implementation simply looks like Go.
Adapter Pattern
Ports and Adapters naturally uses the Adapter Pattern.
Suppose our application expects:
type Notifier interface {
Notify(
context.Context,
Message,
) error
}But an external SDK exposes:
client.SendEmail(...)We create a translation layer:
type EmailAdapter struct {
client *email.Client
}
func (a *EmailAdapter) Notify(
ctx context.Context,
message Message,
) error {
return a.client.SendEmail(
ctx,
message.To,
message.Subject,
message.Body,
)
}The third-party SDK stays outside our core.
If that SDK changes, only the adapter should need substantial modification.
Decorator Pattern
Go middleware is essentially a very natural form of the Decorator Pattern.
Imagine:
type Repository interface {
Save(
context.Context,
Order,
) error
}We can add observability without touching the database implementation:
type LoggingRepository struct {
next Repository
}
func (r *LoggingRepository) Save(
ctx context.Context,
order Order,
) error {
slog.Info(
"saving order",
"order_id", order.ID,
)
return r.next.Save(ctx, order)
}Then compose it:
repo :=
&LoggingRepository{
next: postgresRepo,
}The same idea works for:
metrics
tracing
retry logic
caching
authorization
rate limitingThis is often much cleaner than adding infrastructure concerns directly to business logic.
Functional Options for Construction
Another pattern commonly seen in Go is the functional-options pattern.
Instead of constructors growing into:
NewServer(
host,
port,
timeout,
logger,
tracer,
metrics,
retryCount,
)we can write:
type Option func(*Server)
func WithLogger(
logger *slog.Logger,
) Option {
return func(s *Server) {
s.logger = logger
}
}Then:
server := NewServer(
WithLogger(logger),
WithTimeout(5*time.Second),
)This is particularly useful when many configuration values are optional.
But, like every pattern, it should solve an actual problem.
If your constructor has three obvious required dependencies, a normal constructor is clearer.
Testing Becomes Much Easier
One of the best architectural tests is:
Can I test the business operation without starting infrastructure?
With the repository boundary:
type fakeRepository struct {
saved []order.Order
}
func (r *fakeRepository) Save(
ctx context.Context,
value order.Order,
) error {
r.saved = append(r.saved, value)
return nil
}we can test:
func TestCreateOrder(
t *testing.T,
) {
repo := &fakeRepository{}
service :=
order.NewCreateService(repo)
err := service.Create(
context.Background(),
"customer-123",
)
if err != nil {
t.Fatal(err)
}
if len(repo.saved) != 1 {
t.Fatalf(
"expected one saved order, got %d",
len(repo.saved),
)
}
}No PostgreSQL.
No container.
No network.
No HTTP server.
The infrastructure adapter should still have integration tests.
But the core business behaviour can now be tested independently.
This is exactly the kind of isolation that Ports and Adapters was designed to enable.
What About Backend Applications?
For a backend service, I generally think in terms of:
Transport
↓
Application
↓
Domain
↓
Ports
↑
Infrastructure adaptersTransports might include:
HTTP
gRPC
Kafka
SQS
CronInfrastructure might include:
PostgreSQL
DynamoDB
Redis
S3
Stripe
SMTP
Kafka producersThe application layer coordinates use cases.
The domain contains business behaviour.
The adapters translate between external systems and the application.
What About Web Applications?
For a web application, MVC can still be valuable.
I would simply place MVC at the edge.
For example:
Browser
↓
HTTP Controller
↓
Application Use Case
↓
Domain
↓
Repository Port
↑
PostgreSQL AdapterThe controller may choose a view.
Controller
↓
TemplateBut the domain should not know HTML templates exist.
The use case should not know whether its result becomes JSON, HTML or protobuf.
What About CLI Applications?
For simple CLIs:
main.go
command.go
client.gomay be enough.
For larger CLIs:
cmd/
mycli/
main.go
internal/
command/
application/
github/
config/can work well.
If the CLI and API perform the same business operation, they should ideally call the same application service.
HTTP ─────┐
│
CLI ──────┼──→ Application
│
Worker ───┘That prevents business rules from being duplicated across entry points.
What About Workers and Event Consumers?
A Kafka consumer is conceptually not very different from an HTTP handler.
Both receive external input.
Both translate it.
Both trigger an application operation.
Kafka Message
↓
Kafka Adapter
↓
Application Command
↓
Use CaseThat perspective is surprisingly useful.
Once you stop treating HTTP as the center of the universe, many architectures become simpler.
HTTP is just one adapter.
Kafka is another.
CLI is another.
Tests are another.
Don't Leak Infrastructure Types Into the Core
Consider:
func CreateOrder(
row sql.Row,
) errorNow SQL has leaked inward.
Or:
func ProcessOrder(
msg kafka.Message,
) errorNow Kafka has leaked inward.
Or:
func CreateUser(
req *http.Request,
) errorNow HTTP has leaked inward.
Instead, translate at the boundary.
type CreateOrderCommand struct {
CustomerID string
Items []Item
}Now your use case speaks the language of the application.
Adapters deal with the language of technology.
That separation is one of the strongest indicators of a maintainable architecture.
Don't Turn Every Struct Into a Layer
Architecture can also become a form of procrastination.
I have seen structures where executing one operation means navigating through:
Controller
↓
Request DTO
↓
Mapper
↓
Application Service
↓
Command
↓
Domain Service
↓
Repository Interface
↓
Repository Implementation
↓
Database Mapper
↓
Database EntitySometimes all of those boundaries are justified.
Often they are not.
The goal is not maximum separation.
The goal is useful separation.
A good question to ask is:
What change does this abstraction allow me to make independently?
If the answer is unclear, the abstraction may not yet be necessary.
Architecture Should Follow Volatility
A useful way to think about boundaries is in terms of how frequently things change.
Business concepts might survive for years:
Order
Customer
Subscription
InvoiceInfrastructure changes much more frequently:
PostgreSQL
Redis
Kafka
AWS SDK
HTTP router
Observability libraryYour application architecture should prevent volatile implementation details from controlling stable business policy.
That is the deeper reason behind dependency inversion.
It is not about interfaces for their own sake.
It is about protecting stable code from unstable code.
My Preferred Progression
I do not think every Go application should start with Hexagonal Architecture.
I prefer evolving through complexity.
A tiny application:
main.goA slightly larger application:
main.go
server.go
database.go
orders.goA growing server:
cmd/
api/
internal/
order/
customer/
postgres/
httpapi/A system with multiple transports or infrastructure implementations:
cmd/
api/
worker/
cli/
internal/
order/
customer/
httpapi/
cli/
messaging/
postgres/
redis/
stripe/At that point Ports and Adapters usually emerges naturally.
The architecture grows with the problem.
The problem should not be forced into the architecture.
A Useful Mental Model
When designing a Go application, I find this question more useful than asking whether the project uses MVC, Clean Architecture, Onion Architecture or Hexagonal Architecture:
What code should survive if I replace every external technology tomorrow?
Imagine deleting:
PostgreSQL
Redis
Kafka
Gin
Chi
Cobra
AWS
StripeWhat remains?
Ideally, the important behaviour of your application.
Orders
Payments
Subscriptions
Users
Business rules
Use casesThat is your core.
Everything else connects to it.
The Architecture Test
If you are unsure whether the boundaries are working, try these thought experiments.
Could you replace PostgreSQL with an in-memory implementation without rewriting your business logic?
Could you expose a use case through both HTTP and a CLI?
Could the same application operation be triggered by Kafka?
Could you test a use case without opening a network connection?
Could you replace a third-party API client without modifying your domain?
Could you understand what the application does just by looking at its major package names?
If most answers are yes, your boundaries are probably doing useful work.
If replacing the database requires rewriting the application's business logic, the database is not an adapter.
It is part of your architecture.
Final Thoughts
Design patterns are useful vocabulary.
MVC gives us a way to separate presentation responsibilities.
Repository can isolate persistence concerns.
Strategy makes behaviour replaceable.
Adapter translates between incompatible boundaries.
Decorator adds behaviour around another implementation.
Dependency Injection controls how implementations are assembled.
Ports and Adapters brings many of these ideas together at an architectural level.
But Go encourages us to implement these patterns with restraint.
Use small interfaces.
Prefer composition.
Define interfaces where they are consumed.
Keep infrastructure at the edges.
Keep main responsible for wiring concrete implementations.
Organize packages around meaningful concepts rather than creating giant util, common, or interfaces buckets.
And, perhaps most importantly:
Do not start by asking which architecture diagram your application should follow.
Start by asking:
What parts of this application need to change independently?
Then put a boundary there.
The best Go architecture is not the one with the most layers.
It is the one where changing PostgreSQL to DynamoDB, REST to CLI, or one external API to another feels boring.
Because boring changes are usually a sign that the architecture is doing its job.