Hello! Welcome back to our course on building advanced Go applications.
In our last lesson, we laid the architectural groundwork for our simple-bank's data layer. We defined the Account model and, most importantly, we created the Storage interface. This interface acts as a strict contract, defining what data operations our application can perform, while deliberately hiding how they are performed.
Today's Learning Outcome: Apply the repository pattern to abstract data access behind the storage interface.
Now, we'll bring that contract to life by building our first concrete implementation. We will apply the repository pattern to create a fully-functional, in-memory data store that satisfies the Storage interface. This is a crucial step that allows us to develop and test our application's business logic without the immediate overhead of setting up a live database.
The Repository Pattern in Practice
The repository pattern separates the logic that retrieves data and maps it to the entity model from the business logic that acts on the model. Our Storage interface defines the contract for the repository. The implementation is a concrete Go struct that fulfills this contract.

Creating a concrete implementation of an interface is the practical application of this pattern. To see what this looks like, let's watch a short segment that demonstrates creating a struct and a constructor function for a PostgreSQL repository. While we'll be building an in-memory version first, the structural concept is identical.
Golang Dependency Injection Made EASY - The Repository Pattern
In this clip from 'Golang Dependency Injection Made EASY' by samvcodes, you'll see how a concrete PostgresUserRepo struct is defined to implement the UserRepo interface. Notice the use of a constructor function New... to create an instance of the repository.
Watch from 02:37 to 04:52. Pay attention to how the postgresUserRepo struct is defined and how the NewPostgresUserRepo function returns a pointer to this concrete struct.
Hands-On: Building an In-Memory Repository
Now, let's build our own implementation for the simple-bank project. We'll create an in-memory store that keeps data in a simple map. This is perfect for getting started and for writing fast unit tests later.
Step 1: Define a Shared Error
It's good practice for different repository implementations to return the same set of domain-specific errors. For example, if an account isn't found, the caller shouldn't care if it wasn't found in a PostgreSQL table or an in-memory map.
Let's define a shared ErrAccountNotFound error.
Open your internal/storage/storage.go file and add the following var block after the imports:
package storage
import (
"context"
"errors" // Add this import
"simple-bank/pkg/models"
)
// ErrAccountNotFound is returned when an account is not found.
var ErrAccountNotFound = errors.New("account not found")
// Storage defines the interface for all data storage operations.
// ... (rest of the interface is unchanged)
Step 2: Create the MemoryStorage Struct
- Create a new file in the
internal/storage/directory namedmemory.go. - In this new file, define the struct that will hold our in-memory data.
Add the following code to internal/storage/memory.go:
package storage
import (
"context"
"simple-bank/pkg/models"
"sync"
)
// MemoryStorage is an in-memory implementation of the Storage interface.
// It is safe for concurrent use.
type MemoryStorage struct {
mu sync.RWMutex
accounts map[int64]models.Account
nextID int64
}
// NewMemoryStorage creates and initializes a new MemoryStorage instance.
func NewMemoryStorage() *MemoryStorage {
return &MemoryStorage{
accounts: make(map[int64]models.Account),
nextID: 1,
}
}
Let's break this down:
mu sync.RWMutex: A read-write mutex is crucial. Since multiple concurrent requests could try to access our data, we need to protect ouraccountsmap from race conditions.RWMutexallows multiple readers to access the data simultaneously but ensures exclusive access for writers.accounts map[int64]models.Account: This map will be our "database table," storingAccountstructs keyed by their ID.nextID int64: A simple counter to simulate an auto-incrementing primary key.NewMemoryStorage(): This is a constructor function. It's idiomatic Go to provide aNew...function to create and initialize your structs. It ensures that theaccountsmap is properly created before use. We will use this function for dependency injection in the next lesson.
Step 3: Implement the Storage Interface Methods
Now we'll add methods to *MemoryStorage to satisfy the Storage interface. Go will automatically detect that our struct implements the interface as long as all the methods match the required signatures.
Add the following methods to internal/storage/memory.go.
CreateAccount
This method requires exclusive write access, so we use mu.Lock().
func (s *MemoryStorage) CreateAccount(ctx context.Context, account *models.Account) error {
s.mu.Lock()
defer s.mu.Unlock()
account.ID = s.nextID
s.accounts[account.ID] = *account
s.nextID++
return nil
}
GetAccountByID
This is a read-only operation, so we can use the more permissive mu.RLock().
func (s *MemoryStorage) GetAccountByID(ctx context.Context, id int64) (*models.Account, error) {
s.mu.RLock()
defer s.mu.RUnlock()
account, ok := s.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
return &account, nil
}
DeleteAccount
Another write operation. We must check if the account exists before trying to delete it.
func (s *MemoryStorage) DeleteAccount(ctx context.Context, id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[id]; !ok {
return ErrAccountNotFound
}
delete(s.accounts, id)
return nil
}
ListAccounts
This is a read operation. We'll iterate over the map and collect all accounts that match the owner.
func (s *MemoryStorage) ListAccounts(ctx context.Context, owner string, limit, offset int) ([]models.Account, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// In a real database, filtering and pagination would be done by the query.
// Here, we have to do it manually.
var results []models.Account
for _, account := range s.accounts {
if account.Owner == owner {
results = append(results, account)
}
}
// Manual pagination
if offset >= len(results) {
return []models.Account{}, nil
}
end := offset + limit
if end > len(results) {
end = len(results)
}
return results[offset:end], nil
}
UpdateAccountBalance
This is a critical write operation that must find the account, update its balance, and save it back to the map.
func (s *MemoryStorage) UpdateAccountBalance(ctx context.Context, id int64, amount int64) (*models.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
account, ok := s.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
account.Balance += amount
s.accounts[id] = account
return &account, nil
}
Step 4: Add a Compile-Time Guarantee
How can we be absolutely sure that *MemoryStorage satisfies our Storage interface? We can add a static check that will cause a compile error if the interface is ever not met. This is a robust pattern that prevents runtime surprises if you later change the interface or the struct.
Add this line to the bottom of internal/storage/memory.go:
// var _ Storage = (*MemoryStorage)(nil) is a compile-time check to ensure
// that *MemoryStorage satisfies the Storage interface.
var _ Storage = (*MemoryStorage)(nil)
This line doesn't generate any code or have any runtime cost. It declares a variable of type Storage and assigns a nil pointer of type *MemoryStorage to it. If *MemoryStorage doesn't implement Storage, the assignment will fail at compile time.
Why This Matters
To understand the value of this abstraction, it's helpful to see a detailed, practical walkthrough of building a repository. The following article provides an excellent step-by-step guide for creating a pgx repository, which we will do in a later lesson. Reading it now will solidify the concepts we've just practiced and show you where we're heading.
Mastering Data Access in Go with Repositories & sqlc (2) - Dev.to
The article 'Mastering Data Access in Go with Repositories & sqlc (2)' provides a complete, manual implementation of a pgx user repository. It's a great preview of our next steps with PostgreSQL.
Read the section 'Code Deep Dive: Building a Manual Go Repository'. Notice how the structure—defining a struct, creating a constructor, and implementing interface methods—is exactly what we did for our MemoryStorage, just with SQL queries instead of map lookups.
Conclusion
Congratulations! You have now successfully applied the repository pattern to create your first concrete data storage implementation. You have a fully functional, thread-safe, in-memory Storage backend for the simple-bank application. This is a significant milestone that decouples your future business logic from any specific database technology.
Key Takeaways:
- A repository implementation is a concrete struct (like
MemoryStorage) that provides the logic for an interface's methods (Storage). - An in-memory repository is a powerful tool for rapid development and isolated testing, allowing you to build your application's logic before setting up a database.
- Protecting concurrent access to shared data structures like maps is non-negotiable.
sync.RWMutexis the standard tool for this in Go. - A compile-time check (
var _ Interface = (*Struct)(nil)) is a simple and effective way to guarantee that your implementation correctly satisfies its interface contract.
Next Up
We now have the two essential pieces of our data abstraction: the Storage interface (the "port") and the MemoryStorage struct (the "adapter"). The final step in this module is to connect them to the parts of our application that need to perform data operations.
In the next lesson, we will cover dependency injection, a technique for "wiring up" our MemoryStorage repository to the services that will use it, allowing us to easily swap it out for a PostgreSQL implementation later.