Welcome back!
In the first module, we successfully bootstrapped our simple-bank project from the ground up. We established a clean project structure, automated our build process with a Makefile, and ensured our builds are reproducible and self-contained by vendoring dependencies. Our project's foundation is now solid.
Now, we shift our focus from project mechanics to application architecture. This module is all about building a robust and flexible data layer for our REST API.
Today's Learning Outcome: Apply the 'accept interfaces, return structs' principle to design a storage interface for the REST API.
We will start by designing the contract for our data storage layer. By using interfaces, a cornerstone of Go's design philosophy, we can decouple our application's business logic from the specific database technology we choose to use. This will make it possible to easily swap our data backend—for example, between a real PostgreSQL database and a simple in-memory store for testing—without rewriting our application code.
The Core Principle: Accept Interfaces, Return Structs
Before we write any code, let's understand a fundamental principle of idiomatic Go API design: accept interfaces, return structs. This guideline promotes flexibility and testability.
To get a firm grasp of this concept, its rationale, and how it's used throughout Go's own standard library, please read the following article.
Designing Go APIs the Standard Library Way
The article 'Designing Go APIs the Standard Library Way' by Shrijith Venkatramana offers an excellent explanation of this core principle. Pay close attention to the power outlet analogy.
Read the article from the beginning down to the end of the 'Real-World Examples from Go’s Standard Library' section. Focus on understanding the distinct roles of interfaces as flexible inputs and structs as concrete, feature-rich outputs.
The key takeaways are:
- Interfaces for Inputs: Functions that accept interfaces are more versatile. They can work with any data type that satisfies the interface's contract, which is invaluable for decoupling components and for testing (where you can pass in a "mock" implementation).
- Structs for Outputs: Functions that return concrete structs (usually as pointers) give the caller full access to the data (fields) and behavior (methods) of the returned type. This avoids hiding functionality behind a restrictive interface.
The Repository Pattern: An Architectural Application
This principle is not just an abstract idea; it's the foundation for powerful architectural patterns. One of the most common and effective patterns for abstracting data access logic is the Repository Pattern.
The repository's job is to mediate between the domain (your business logic) and data mapping layers (your database). It provides a collection-like interface for accessing domain objects. Your business logic will only ever talk to the repository's interface, remaining completely unaware of whether the data is coming from PostgreSQL, an in-memory map, or a remote API.
This pattern is a key element of both Domain-Driven Design (DDD) and Hexagonal Architecture (also known as Ports and Adapters). Given your interest in system design, these diagrams can help contextualize where the repository fits.


To see how this looks in Go code, let's watch a short video that walks through creating a repository interface and injecting it into a service.
Golang Dependency Injection Made EASY - The Repository Pattern
The video 'Golang Dependency Injection Made EASY' by samvcodes provides a clear, practical demonstration of building and using a repository interface.
Watch the sections covering the UserRepo interface and the UserService integration. The first part shows the definition of a UserRepo interface. The second part demonstrates how this interface is accepted by a UserService, decoupling the service from the concrete database implementation.
Hands-On: Designing Our Storage Interface
Now, let's apply these concepts to our simple-bank project. We will define the data model for a bank account and then create the Storage interface that will govern all data operations.
Step 1: Define the Account Model
First, we need a struct to represent a bank account. For models that might be shared across different parts of our application (like API handlers and storage implementations), the pkg/ directory is a suitable location.
- Create a new directory:
pkg/models. - Inside this directory, create a new file named
account.go.
Add the following code to pkg/models/account.go:
package models
import "time"
// Account represents a single bank account in the system.
type Account struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
CreatedAt time.Time `json:"created_at"`
}
A quick note on the fields:
- We use
int64forIDandBalance. Using integers for money is a common practice to avoid floating-point inaccuracies; you would store the smallest unit (e.g., cents). - The
json:"..."tags will be used later when we build our JSON API, telling theencoding/jsonpackage how to name the fields in the JSON output.
Step 2: Define the Storage Interface
Next, we'll define the repository interface. This belongs in our internal directory, as it's specific to the internal workings of our simple-bank application.
- Create a new directory:
internal/storage. - Inside this directory, create a new file named
storage.go.
Add the following code to internal/storage/storage.go:
package storage
import (
"context"
"simple-bank/pkg/models"
)
// Storage defines the interface for all data storage operations.
// This is the contract that all storage backends (e.g., PostgreSQL, in-memory) must satisfy.
type Storage interface {
CreateAccount(ctx context.Context, account *models.Account) error
GetAccountByID(ctx context.Context, id int64) (*models.Account, error)
ListAccounts(ctx context.Context, owner string, limit, offset int) ([]models.Account, error)
DeleteAccount(ctx context.Context, id int64) error
UpdateAccountBalance(ctx context.Context, id int64, amount int64) (*models.Account, error)
}
Let's break down this interface design:
package storage: This package will contain our storage interface and its different implementations.import "context": All methods accept acontext.Contextas their first argument. This is a critical pattern in modern Go for handling cancellations, timeouts, and passing request-scoped values. We will explore this in depth later.GetAccountByID(...) (*models.Account, error): This method follows our principle perfectly. It returns a concrete*models.Accountstruct, giving the caller full access to the account's data.UpdateAccountBalance(...) (*models.Account, error): An update operation that returns the modified account struct. This is useful for confirming the new state after an update.CreateAccount(...) error: This method takes a pointer to anAccountstruct to create. It doesn't need to return the created account since the caller already has the pointer (and we might populate theIDandCreatedAtfields on that pointer). It just returns an error if something goes wrong.
You've now defined the complete contract for your application's data layer. Any component that needs to interact with data will do so through this Storage interface, without needing to know anything about the underlying database.
Conclusion
In this lesson, you've taken the first and most crucial step in building a flexible, multi-backend application. By applying the "accept interfaces, return structs" principle via the Repository pattern, you've created a clean architectural boundary between your application's logic and its data persistence mechanism.
Key Takeaways:
- The "accept interfaces, return structs" principle is a cornerstone of idiomatic Go that promotes flexible and testable code.
- The Repository Pattern is a powerful architectural tool that uses an interface to abstract data access logic, decoupling your application from specific database implementations.
- We created a concrete
Accountmodel inpkg/modelsto represent our core domain entity. - We designed a
Storageinterface ininternal/storagethat serves as the definitive contract for all data operations in our application.
Next Up
With our storage contract defined, the next logical step is to create a concrete implementation that fulfills it. In the next lesson, we will focus on the next learning outcome: "Apply the repository pattern to abstract data access behind the storage interface." We will begin by building a simple, in-memory implementation of our Storage interface. This will allow us to proceed with developing our API logic and writing tests without the immediate overhead of setting up a database.