Dev Skills

Step 2 — The store, in memory

Somewhere to keep links, so you can build the API before adding a database.

Topic 60 of 98 · 5 min to read · 45 min to build
After this topic you can
  • Build an in-memory link store behind a small set of methods.
  • Explain why an RWMutex suits a store that is read far more than written.
  • Return a sentinel error such as ErrNotFound from the store.

Every command for this step

terminal — run these in order
mkdir -p internal/model internal/store internal/service internal/transport/web
touch internal/model/link.go internal/store/memory.go
# after writing them
go build ./...
go vet ./...
# a quick test of the store
touch internal/store/memory_test.go
go test ./internal/store/ -run TestMemory -v

Why memory first

You could add Postgres now. Doing it later is better: an in-memory store takes ten minutes, has no setup, and lets you get the whole API working before you introduce anything that can fail for reasons unrelated to your code.

It also forces a useful shape. If the rest of your program only talks to the store through a few methods, swapping them for SQL later touches only the store package. Step 5 does that.

Two new folders

internal/
transport/from step 1
web/
respond.go
model/new — the types, and nothing else
link.gothe Link struct and its errors
store/new — where links are kept
memory.gothe in-memory store
Two new packages, one per layer. `model` holds the type and `store` keeps it. `service`, which decides the rules, gets its first file in step 3.

The type first. The struct tags decide what the JSON looks like, so this file also defines your API’s response shape.

internal/model/link.go — create this file
package model
import (
"errors"
"time"
)
// Link is the only type this service has. It lives in model/ because it is
// what the application is ABOUT — no HTTP, no SQL, just the shape.
type Link struct {
Code string `json:"code"`
LongURL string `json:"long_url"`
Clicks int64 `json:"clicks"`
CreatedAt time.Time `json:"created_at"`
}
// The errors this service can return. The transport layer turns these into
// status codes, so it never needs to know how links are stored.
var (
ErrNotFound = errors.New("link not found")
ErrCodeTaken = errors.New("code already in use")
ErrValidation = errors.New("invalid input")
)
Defining the errors here, next to the type, is what lets the handler write errors.Is(err, model.ErrNotFound) later.

File 2 — internal/store/memory.go

The store keeps links in a map. The mutex is not optional: an HTTP server handles each request in its own goroutine, so two requests can write to that map at the same moment. Go usually detects that and crashes the process.

internal/store/memory.go — create this file
package store
import (
"context"
"sync"
"github.com/you/shortener/internal/model"
)
// Memory keeps links in a map. Everything is lost on restart, which is fine
// until step 5 swaps this for Postgres.
type Memory struct {
mu sync.RWMutex // guards the map below
links map[string]*model.Link // keyed by code
}
func NewMemory() *Memory {
return &Memory{links: map[string]*model.Link{}}
}
func (s *Memory) Save(ctx context.Context, l *model.Link) error {
s.mu.Lock() // a write: exclusive
defer s.mu.Unlock()
if _, exists := s.links[l.Code]; exists {
return model.ErrCodeTaken
}
s.links[l.Code] = l
return nil
}
func (s *Memory) ByCode(ctx context.Context, code string) (*model.Link, error) {
s.mu.RLock() // a read: many at once are fine
defer s.mu.RUnlock()
l, ok := s.links[code]
if !ok {
return nil, model.ErrNotFound // absent is not the same as an empty link
}
return l, nil
}

Why RWMutex here

A mutex lets one goroutine at a time into a section of code. An RWMutex is a mutex that also allows many readers at once, as long as nobody is writing.

That fits a URL shortener: every redirect is a read, and creating a link is comparatively rare. When reads heavily outnumber writes, RWMutex lets them run in parallel instead of queueing. For a map lookup this short the gain is small, so measure before you rely on it.

Watch out: Maps are not safe for use from several goroutines at once when any of them writes. Without the lock, the runtime usually detects the clash and deliberately crashes the whole process with "fatal error: concurrent map writes". Crashing beats corrupting your data, but it still takes your service down, and recover cannot catch it.
The short version
  • Build against memory first. A database is a step you take once the API already works.
  • Keep the type and its errors in model, and the code that stores it in store.
  • Every request runs in its own goroutine, so a shared map needs a lock.
  • RWMutex when reads heavily outnumber writes, which is the case here.
Remember: Talk to storage through a few named methods. That is what makes swapping memory for Postgres a one-file change later.
Build this before moving on
Create both files. Write a small test that saves a link, reads it back, and checks that reading a missing code returns ErrNotFound.
Then try these in your own editor
  1. 1Add a Delete(code string) error method that returns ErrNotFound when the code is not there.
  2. 2Remove the mutex, then run go test -race with two goroutines writing at once. Read the report.
  3. 3Change ByCode to return (Link, bool) instead of an error, then decide which version you prefer and why.
Check yourselfTake the Project 1 — URL shortener test