Hello! Welcome back to our course on advanced Go.
In our last lesson, we successfully wired our PostgreSQL repository into the API handlers using dependency injection. We created an APIServer struct that depends on a storage.Storage interface, and in our main function, we injected a concrete *PostgresStorage implementation. This was a crucial step in decoupling our application's business logic from its data layer.
Your Goal for Today
Learning Outcome: Implement an in-memory storage backend satisfying the same repository interface for testing and development.
Today, you'll see the real power of that decoupling. We are going to create a second, completely different implementation of our storage.Storage interface. Instead of connecting to a PostgreSQL database, this new backend will store data directly in memory using a map.
This is a fundamental pattern in modern software development. An in-memory fake or mock of a dependency is invaluable for fast, isolated unit tests and for running the application locally without needing to spin up a database. By the end of this lesson, you'll have two "pluggable" storage modules for our application, demonstrating a key principle of good system design.
1. The Power of an Abstraction Layer
The repository pattern, which we've been implementing, creates a critical abstraction layer. Our APIServer and its handlers are the "Client" in this pattern. They know what they want to do (e.g., CreateAccount), but they don't know—and don't need to know—how it gets done. That implementation detail is hidden behind the storage.Storage interface.

The ability to swap implementations is one of the main benefits of this pattern. We can have a high-performance, persistent database for production and a simple, fast, in-memory version for testing.
To get a better sense of this concept, the following video segment explains this exact motivation.
How To Implement Domain-Driven Design (DDD) in Go
In the video 'How To Implement Domain-Driven Design (DDD) in Go', the presenter from ProgrammingPercy explains the repository pattern and highlights its key benefit: the ability to swap implementations.
Watch the segment from 00:29:48 to 00:32:04. Pay attention to the discussion about having an in-memory repository for unit tests and a MySQL/MongoDB repository for production, and how the interface makes this possible.
2. Building the InMemoryStorage
Let's get practical and build our in-memory repository. We'll create a new file, internal/storage/inmemory.go, for this implementation.
The core of our in-memory storage will be a Go map to hold the account data. Because our HTTP server will handle multiple requests concurrently in different goroutines, multiple goroutines could try to read from and write to our map at the same time. This would cause a race condition. To prevent this, we must use a mutex to synchronize access. A sync.RWMutex is a perfect choice here, as it allows multiple concurrent reads but exclusive access for writes.
Step 1: Define the InMemoryStorage Struct
In internal/storage/inmemory.go, define the struct and a constructor function.
// internal/storage/inmemory.go
package storage
import (
"fmt"
"sync"
)
// InMemoryStorage implements the Storage interface for in-memory data storage.
type InMemoryStorage struct {
mu sync.RWMutex
accounts map[int64]*Account
nextID int64
}
// NewInMemoryStorage creates and returns a new InMemoryStorage instance.
func NewInMemoryStorage() *InMemoryStorage {
return &InMemoryStorage{
accounts: make(map[int64]*Account),
nextID: 1, // Start IDs from 1
}
}
mu: TheRWMutexto protect our map.accounts: A map where the key is the account ID and the value is a pointer to theAccountstruct.nextID: A simple counter to simulate auto-incrementing primary keys.
Step 2: Implement the Storage Interface Methods
Now, let's implement the methods required by our storage.Storage interface. For each method, we need to be mindful of locking and unlocking the mutex.
CreateAccount
This method modifies the map, so we must use a full lock (mu.Lock()).
// internal/storage/inmemory.go (continued)
func (s *InMemoryStorage) CreateAccount(acc *Account) error {
s.mu.Lock()
defer s.mu.Unlock()
// In a real DB, we might check for duplicate emails, etc.
// For this in-memory store, we'll keep it simple.
acc.ID = s.nextID
s.accounts[acc.ID] = acc
s.nextID++
return nil
}
GetAccountByID
This method only reads from the map, so we can use a read lock (mu.RLock()), which is more performant as it allows other reads to happen concurrently.
// internal/storage/inmemory.go (continued)
func (s *InMemoryStorage) GetAccountByID(id int64) (*Account, error) {
s.mu.RLock()
defer s.mu.RUnlock()
acc, ok := s.accounts[id]
if !ok {
return nil, fmt.Errorf("account with id %d not found", id)
}
return acc, nil
}
Remaining Methods
Let's fill in the other methods to fully satisfy the interface.
// internal/storage/inmemory.go (continued)
func (s *InMemoryStorage) GetAccounts() ([]*Account, error) {
s.mu.RLock()
defer s.mu.RUnlock()
accounts := make([]*Account, 0, len(s.accounts))
for _, acc := range s.accounts {
accounts = append(accounts, acc)
}
return accounts, nil
}
func (s *InMemoryStorage) UpdateAccount(acc *Account) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[acc.ID]; !ok {
return fmt.Errorf("account with id %d not found", acc.ID)
}
s.accounts[acc.ID] = acc
return nil
}
func (s *InMemoryStorage) DeleteAccount(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[id]; !ok {
return fmt.Errorf("account with id %d not found", id)
}
delete(s.accounts, id)
return nil
}
This implementation pattern is very common. For a complete code example of a similar in-memory repository, you can review the following resource.
The Repository pattern in Go: a painless way to simplify your service ...
The article 'The Repository pattern in Go' from threedots.tech provides a high-quality example of an in-memory repository that also uses a map and a sync.RWMutex.
Review the section 'Example In-memory implementation'. Note the similarities: a struct with a map and a mutex, a constructor function, and method implementations that lock and unlock the mutex.
3. Compile-Time Interface Check
How can we be absolutely sure our InMemoryStorage correctly implements the Storage interface? We could wait for the compiler to complain when we try to use it, but there's a better way: a compile-time check.
This is a common idiom in Go. By adding a single line to our file, we can ask the compiler to verify the interface satisfaction for us.
Add this line to internal/storage/inmemory.go, right after your imports:
// internal/storage/inmemory.go
import (
"fmt"
"sync"
)
// This line ensures that InMemoryStorage satisfies the Storage interface at compile time.
var _ Storage = (*InMemoryStorage)(nil)
// ... rest of the file
How it works:
var _ Storage: We declare a variable named_(the blank identifier, meaning we don't care about the value) of the typeStorage(our interface).= (*InMemoryStorage)(nil): We assign anilpointer of our concrete type*InMemoryStorageto it.
If *InMemoryStorage doesn't correctly implement all the methods of the Storage interface, this assignment will fail, and the Go compiler will throw an error. This is a simple, effective way to catch interface-related bugs early.
Conclusion
Excellent work! You have now created a complete, concurrent-safe, in-memory implementation of our storage layer.
Key Takeaways:
- The repository pattern allows for multiple, swappable implementations of a data storage interface.
- An in-memory implementation is fast, lightweight, and ideal for local development and automated testing.
- For an in-memory store used in a concurrent application (like a web server), access to shared data structures (like maps) must be synchronized using mutexes (
sync.Mutexorsync.RWMutex). - A compile-time check (
var _ Interface = (*Struct)(nil)) is an idiomatic Go practice to ensure a struct satisfies an interface.
We now have two concrete types that satisfy the storage.Storage interface:
PostgresStorage: Persistent, robust, for production.InMemoryStorage: Ephemeral, fast, for testing and development.
Next Up
Our toolbox is ready. In the next lesson, we'll put this new InMemoryStorage to use. We will enhance our application's configuration and main function to allow us to choose which storage backend to launch—PostgreSQL or in-memory—with a simple configuration change, proving the power and flexibility of the design we've built.