JavaRush /Courses /Go SELF /Context propagation: HTTP → app → storage; CLI → import →...

Context propagation: HTTP → app → storage; CLI → import → storage

Go SELF
Level 70, Lesson 3
Available

1. Why propagate ctx across layers

If you have already written HTTP handlers or CLI commands, you have probably had the feeling: “well, I returned a response/printed the result—what difference does it make?” The difference shows up when the user leaves, the connection drops, the timeout expires, or the operation becomes too slow. Without context propagation, the lower layers keep working “into the void”: they read a file, compute, hit storage, hold locks. It is like continuing to cook a pizza after the customer canceled the order—tasty, but the business model suffers.

In Go, context is designed as the standard way to carry three things across API boundaries: cancellation, deadline, and operation metadata. This is especially important in server code, where one incoming request often triggers several sub-operations.

Mental model: one operation → one ctx that flows downward

When we say “propagate context,” it is not magic and not a ritual. It is a straightforward contract: we have an upper operation boundary (an HTTP request or a CLI command), and that is exactly where we obtain/create the root ctx. Then that same ctx (or its child context) is passed down the call stack: handler → app/service → storage. And no layer along the way “resets” it to context.Background().

If you picture the system as a relay, ctx is the baton. Drop the baton—the team keeps running, but the result no longer counts, because cancellation and deadlines got lost. And you will also lose metadata like request_id, and logs turn into a “guess which request this was” quest.

2. Example: Tasker and layer contracts

So the examples are not just a set of abstract fragments, we will assume we have a training application Tasker: it stores tasks and can import them (for example, from CSV). We are not going into complex architecture, but we will stick to a clear split:

  • HTTP layer: accepts the request, sets a timeout, calls app logic.
  • App layer: business operations (create a task, import tasks).
  • Storage layer: persistence (for the example— in-memory).

Diagram (simplified):

flowchart TD
  HTTP[HTTP handler<br/>r.Context + WithTimeout] --> APP[app.Service]
  APP --> ST[storage.TaskStorage]
  CLI[CLI command<br/>Background + WithTimeout] --> APP
  APP --> ST

The main idea: both HTTP and CLI go into the same app layer, and the context goes as the first parameter everywhere.

Layer contracts: ctx is the first parameter

Here is an important point that can look like “style nitpicking,” but later it saves hours. We do it so that any operation that can be long-running or cancellable accepts ctx as the first parameter. This is a visual marker: “this function respects context.”

Storage interface

Let’s start from the bottom: storage can create a task and return a list.

package storage

import "context"

type Task struct {
	ID    int
	Title string
	Done  bool
}

type TaskStorage interface {
	Create(ctx context.Context, t Task) (Task, error)
	List(ctx context.Context) ([]Task, error)
}

Note: we do not store ctx inside TaskStorage. Context belongs to the call of Create/List, not to “storage as an object.” This is exactly the principle recommended for Go APIs: pass context as an argument rather than saving it in a field.

App/service layer

The app layer “speaks in human terms”: AddTask, ImportCSV.

package app

import (
	"context"
	"tasker/internal/storage"
)

type Service struct {
	st storage.TaskStorage
}

func NewService(st storage.TaskStorage) *Service {
	return &Service{st: st}
}

And the method (briefly, without validation details):

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) AddTask(ctx context.Context, title string) (storage.Task, error) {
	t := storage.Task{Title: title}
	return s.st.Create(ctx, t)
}

Key point: the app layer does not invent a new context; it respects the incoming one and passes it further down.

3. HTTP → app → storage: how not to lose ctx

HTTP is an ideal example of an “operation boundary”: the request started, the request ended. The Go HTTP server already provides a ready request context: r.Context(). It gets canceled when the client leaves or when the handler finishes (depending on the situation). So we have the starting point out of the box.

Handler: take r.Context() and add a timeout

A common practice is to limit request processing time. Not inside storage, not “somewhere deep,” but right in the handler: this is where you understand the client’s expectations.

package httpapi

import (
	"context"
	"net/http"
	"time"
)

func withRequestTimeout(r *http.Request, d time.Duration) (context.Context, func()) {
	ctx, cancel := context.WithTimeout(r.Context(), d)
	return ctx, cancel
}

Now apply it in the handler:

package httpapi

import (
	"net/http"
	"time"
)

func (h *Handler) handleAddTask(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := withRequestTimeout(r, 2*time.Second)
	defer cancel()

	_, _ = h.svc.AddTask(ctx, "buy milk")
	w.WriteHeader(http.StatusCreated)
}

Yes, this example is intentionally simplified (we are not reading JSON/form), because right now we care about the context path. The timeout is set at the boundary, and cancel() is called in a disciplined way.

Why is defer cancel() mandatory even with a timeout? Because WithTimeout creates an internal timer/resources, and early cancellation frees them sooner. This is part of normal hygiene.

The app layer does not “reset” anything

A correct app layer looks boring. And that is a compliment.

package app

import (
	"context"
	"tasker/internal/storage"
)

func (s *Service) ListTasks(ctx context.Context) ([]storage.Task, error) {
	return s.st.List(ctx)
}

The most dangerous beginner “optimization” is to write this:

// BAD: we lost cancellation/timeout/metadata.
return s.st.List(context.Background())

This breaks the whole idea: the handler could have been canceled, but storage will never know.

Storage: at least minimally check ctx.Done()

In an in-memory store the operation is usually fast, but we build the habit here too. Let List check cancellation before doing work (at minimum)—that makes the code behave predictably even if later you replace storage with file-based or network storage.

package memstore

import (
	"context"
	"sync"
	"tasker/internal/storage"
)

type Store struct {
	mu    sync.Mutex
	tasks []storage.Task
	next  int
}

func (s *Store) List(ctx context.Context) ([]storage.Task, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	out := append([]storage.Task(nil), s.tasks...)
	return out, nil
}

Here we did two important things: checked cancellation and copied the slice (so we do not return “live” internal memory to the outside). In the context of this lecture, the key part is ctx.Err() as the correct reason for termination.

4. CLI → import → storage: Background() at the boundary

CLI differs from HTTP in one important detail: you do not have a ready r.Context(). But you do have an “operation boundary” in the form of the command. That means: you create the root context yourself, typically via context.Background().

In a real application you often add cancellation on OS signals, but today it is enough to understand the path: CLI creates ctx and propagates it to app and storage.

CLI entry point: create ctx and set a command timeout

Let’s imagine the command tasker import --file tasks.csv. We want the import not to hang forever.

package main

import (
	"context"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_ = ctx
	// next: open file and call svc.ImportCSV(ctx, f)
}

Here again the timeout is set at the operation boundary. This is the same principle as in HTTP.

The import app method takes ctx and io.Reader

Import is a great example of “layers”: CLI works with the file, but the app layer does not have to know about os.File. It is enough for it to accept an io.Reader. And—context.

package app

import (
	"context"
	"encoding/csv"
	"fmt"
	"io"
)

func (s *Service) ImportCSV(ctx context.Context, r io.Reader) (int, error) {
	cr := csv.NewReader(r)

	n := 0
	for {
		select {
		case <-ctx.Done():
			return n, ctx.Err()
		default:
		}

		rec, err := cr.Read()
		if err == io.EOF {
			return n, nil
		}
		if err != nil {
			return n, fmt.Errorf("read csv: %w", err)
		}

		if len(rec) == 0 {
			continue
		}
		if _, err := s.AddTask(ctx, rec[0]); err != nil {
			return n, err
		}
		n++
	}
}

This code does two things that matter most for us today. First, it regularly checks ctx.Done() inside the loop—this is an “exit point” for a long-running operation. Second, it does not create a new context: it uses exactly the one that came from above.

CLI layer: opened the file, passed ctx down

Now let’s connect this with os.Open. The CLI layer handles file details; the app layer handles import as a business operation.

package main

import (
	"context"
	"os"
	"time"
)

func runImport(path string, svc *Service) error {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()

	_, err = svc.ImportCSV(ctx, f)
	return err
}

Notice the “cultural layer” of the code: ctx appears at the top level of the function, and then it goes down—like a suitcase without a handle that we still carry because it is our suitcase.

5. Timeouts and the context lifecycle

Where to set timeouts: at the boundary or inside

The question “where do I set a timeout” feels philosophical until you catch a bug: the request died, but your storage keeps working. The general practice is: set the timeout where the meaning of the operation is clear to the user.

Below is a small table of “how to think,” without claiming it is the only truth:

Layer Is it allowed to set a timeout? Why
HTTP handler Yes It is closest to the client and understands the request SLA
CLI command Yes It describes the “user operation” (import, export, list)
App layer Sometimes It can set a sub-timeout for a sub-operation, but only derived from the incoming ctx
Storage layer Usually no Storage should respect ctx, not impose its own rules on everyone

If the app layer needs a shorter limit for a sub-operation, it is done only like this: context.WithTimeout(parentCtx, d). Contexts form a tree, and a child context inherits cancellation from the parent rather than replacing it.

Why you cannot store ctx in a struct “for convenience”

This temptation looks innocent: “well, I always call service methods anyway—why not put ctx inside Service?” Because then you mix lifetimes. Context belongs to one operation, while Service lives for a long time. As a result, the next service call can accidentally run with an already canceled context or with a deadline “from yesterday.” And you will get “sometimes it works, sometimes it doesn’t” behavior—the favorite bug genre that steals sleep.

The official Go recommendation for most cases is simple: contexts are not stored in structs; they are passed as arguments to methods, so each call has its own lifecycle and its own deadlines/metadata.

6. Sequence diagrams: HTTP and CLI

Sometimes it helps to see not code, but a “cartoon” of who calls whom and where ctx flows.

HTTP request

sequenceDiagram
  participant C as Client
  participant H as HTTP handler
  participant S as app.Service
  participant ST as storage

  C->>H: Request
  Note over H: ctx = r.Context()<br/>ctx = WithTimeout(ctx, 2s)
  H->>S: AddTask(ctx, title)
  S->>ST: Create(ctx, Task)
  ST-->>S: (Task, nil) or ctx.Err()
  S-->>H: result
  H-->>C: 201 Created

CLI import

sequenceDiagram
  participant U as User
  participant CLI as CLI main
  participant S as app.Service
  participant ST as storage

  U->>CLI: tasker import tasks.csv
  Note over CLI: ctx = Background()<br/>ctx = WithTimeout(ctx, 10s)
  CLI->>S: ImportCSV(ctx, file)
  loop each row
    S->>ST: Create(ctx, Task)
  end
  ST-->>S: ok / ctx.Err()
  S-->>CLI: count / error
  CLI-->>U: exit code + message

And in both branches the same key discipline: ctx is not “zeroed out” and not replaced with Background() in the middle.

7. Common mistakes

Mistake #1: “Reset” the context to context.Background() somewhere in app/storage because it is simpler.
This breaks cancellation and deadlines and makes the upper layer (HTTP/CLI) powerless. As a result, the client left, but you keep working, spend resources, and sometimes hold locks longer than needed. The fix is simple: ctx always comes from above and goes down, with no improvisation.

Mistake #2: Forget defer cancel() after WithTimeout/WithDeadline.
Even if it seems that “the timeout will cancel everything by itself,” cancel() is the discipline of releasing internal resources (timers, references in the context tree). The right habit: create (ctx, cancel)—and on the very next line write defer cancel().

Mistake #3: Put ctx not as the first parameter, but somewhere in the middle of the signature.
Functions start to look inconsistent, and code review turns into “find the context.” The Go ecosystem is used to the rule: ctx context.Context goes first. It is not a law of physics, but it is a very handy “reading standard.”

Mistake #4: Do not check ctx.Done() in long loops (for example, import/traversal/processing of big data).
If an operation has a thousand steps but you never look at ctx.Done(), then cancellation “does not work” in practice: the user canceled, and you keep reading the file to the end. The solution is to place check points inside the loop and correctly return ctx.Err() as the reason for stopping.

Mistake #5: Store ctx in a service/storage struct field “just in case.”
This mixes lifecycles of different operations: one call can accidentally cancel another, and deadlines become unpredictable. Instead, pass context as an argument to each method that belongs to a specific operation.

1
Task
Go SELF, level 70, lesson 3
Locked
Three layers
Three layers
1
Task
Go SELF, level 70, lesson 3
Locked
Timeout at the operation boundary
Timeout at the operation boundary
1
Task
Go SELF, level 70, lesson 3
Locked
Test handler
Test handler
1
Task
Go SELF, level 70, lesson 3
Locked
CSV Import
CSV Import
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION