JavaRush /Courses /Go SELF /Separation of responsibility: core logic separate from th...

Separation of responsibility: core logic separate from the HTTP layer

Go SELF
Level 64, Lesson 3
Available

1. Why logic in a handler starts getting in the way over time

The first few endpoints are almost always written the same way: in the handler we parse id, read JSON, validate fields, hit the storage, decide which status to return, build JSON, and write it out. While the project is small, it seems like that is exactly how it should be. But then you add a second endpoint, a third, and suddenly you discover that business rules are smeared across HTTP code, and tests turn into “let's check that this branch returned 400 and that one returned 404.”

The main problem is not that the handler is “too long.” The problem is that the HTTP layer starts making decisions that do not belong to it. It should be able to read a request and write a response, but it should not be the “chief thinker” of the system. As soon as a business rule changes, you edit five handlers, six httptest tests, and two more places where “we did almost the same thing.” This is a lot like keeping all application logic in main(): one day it simply stops fitting in your head.

To get out of this trap, we introduce a discipline: core logic separately, HTTP separately. Then the handler becomes a thin translator, not the author of a novel.

What core and the HTTP layer are

When people say “separation of responsibility,” a beginner often hears: “you urgently need 18 folders and 47 interfaces.” That is not true (and yes, I used to think so too). In practice we only need one simple principle: core must not know that it is being called via HTTP. It must be able to perform actions in the terms of the domain: “create a task,” “get a list of tasks,” “mark a task as done,” “return a validation error if the input is bad.”

The HTTP layer, on the contrary, should be good at what HTTP is good at: parse URL, method, headers, JSON body, choose a status code, set Content-Type, return an error envelope. In Go this usually means that the HTTP layer imports net/http and encoding/json, and the core layer does not.

It is convenient to keep a table like this in mind:

Question Who owns it
“What should we do if title is empty?” Core
“What status do we return on validation?” HTTP layer (mapping)
“How do we parse {id} from the path?” HTTP layer
“What does ‘the task does not exist’ mean?” Core (not found error)
“What is the JSON shape of an error?” HTTP layer (API contract)

Important: this does not mean that core “does not return anything.” It returns results and errors, but in its own terms.

By the way, the fact that ServeMux now supports patterns with methods and {id} makes the HTTP layer more convenient, but it does not make it a business layer. It is still just transport.

2. Contract and implementation of the layers

Contract: inputs, outputs, and domain errors

For the layers to work well together, they need a contract. Ideally one that does not force us to drag *http.Request into core. The contract usually looks like this: core receives “normal” types (int, string, time.Time, structs), and returns either a result or an error. The error can be typed so that the HTTP layer can understand the class: validation/not_found/internal.

In our previous topics we have already gotten used to the fact that errors in Go are values, and you can make decisions based on them via errors.Is/errors.As. This is useful not only for nice error “wrappers,” but also for architecture: core returns a meaningful error, and the HTTP layer does the mapping. In Go this is standard practice: an error can carry context, and you can wrap it on top without losing meaning (via a chain of causes).

For our training task API, we will introduce two domain errors: ValidationError and NotFoundError. They will live in core and know nothing about JSON or status codes.

package core

type ValidationError struct {
	Fields map[string]string
}

func (e ValidationError) Error() string {
	return "validation error"
}

type NotFoundError struct {
	ID int
}

func (e NotFoundError) Error() string {
	return "not found"
}

Notice: the error texts here are “internal.” What the client will see in error.message is decided by the HTTP layer. It is like a translator: the thought is the same, the language is different.

Core layer: model and storage interface

Now we will assemble the minimal “brain” of a task application. Let us say we have a task: ID, Title, Done. This is a pure domain model; it does not need to know about JSON tags, because JSON is a transport form.

package core

type Task struct {
	ID    int
	Title string
	Done  bool
}

Next, core usually does not go directly to the database (or to an in-memory map). It talks through a “storage” interface. This matters not for fashion, but for testability: in core tests we can substitute a fake store without disk, network, and complex setup.

package core

import "context"

type TaskStore interface {
	Create(ctx context.Context, title string) (Task, error)
	GetByID(ctx context.Context, id int) (Task, error)
	MarkDone(ctx context.Context, id int) (Task, error)
}

Note: context.Context is no longer a purely HTTP thing. It has long been used as a “cancellation/timeout channel” and for propagating request-scoped information. So passing ctx into core is normal practice.

Usecase methods: business logic without net/http

Now let's build a “service” (usecase layer). This is an object that implements the rules: validates input, calls storage, interprets storage errors (if needed), and returns domain errors.

package core

import (
	"context"
	"strings"
)

type TaskService struct {
	store TaskStore
}

func NewTaskService(store TaskStore) TaskService {
	return TaskService{store: store}
}

func (s TaskService) Create(ctx context.Context, title string) (Task, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Task{}, ValidationError{Fields: map[string]string{"title": "must not be empty"}}
	}
	return s.store.Create(ctx, title)
}

Notice how “clean” the Create method turned out: no *http.Request, no json.Decoder, no status codes. It is just a “create a task” function.

Now let's implement “mark as done.” An important point appears here: if id is bad (for example <= 0) that is validation. If the task does not exist, that is not found. This is business meaning, not HTTP meaning.

package core

import "context"

func (s TaskService) MarkDone(ctx context.Context, id int) (Task, error) {
	if id <= 0 {
		return Task{}, ValidationError{Fields: map[string]string{"id": "must be positive"}}
	}
	t, err := s.store.MarkDone(ctx, id)
	if err != nil {
		return Task{}, err
	}
	return t, nil
}

For now we just pass the storage error outward. In a real project you often do this: the storage returns NotFoundError, and the service does not change it. The main thing is that the internal meaning of the error is preserved, and the HTTP layer decides how to present it to the client.

HTTP layer: adapter decode → core → encode

Now that core knows how to “think,” the HTTP layer can safely become a translator. It does not have to be smart; it just has to be careful.

Let's create a DTO for the create-task request. DTO is specifically the HTTP form of data, so JSON tags are appropriate here.

package httpapi

type createTaskRequest struct {
	Title string `json:"title"`
}

Now the handler: it reads JSON, extracts Title, calls svc.Create(...), and then maps errors into our error envelope.

package httpapi

import (
	"encoding/json"
	"net/http"

	"example.com/app/core"
)

type TaskHandler struct {
	svc core.TaskService
}

func (h TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
	var req createTaskRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "validation", "invalid request", nil)
		return
	}

	task, err := h.svc.Create(r.Context(), req.Title)
	if err != nil {
		h.writeErr(w, err)
		return
	}

	writeJSON(w, http.StatusCreated, task)
}

Note two things.

First: the handler does not do strings.TrimSpace and does not decide “empty title is bad.” It delegates that to core.

Second: error-mapping logic does not spread inside the handler—we moved it into h.writeErr. This makes the HTTP layer uniform and reduces copy-paste.

An example of writeErr, which recognizes domain errors via errors.As and returns the same response format:

package httpapi

import (
	"errors"
	"net/http"

	"example.com/app/core"
)

func (h TaskHandler) writeErr(w http.ResponseWriter, err error) {
	var vErr core.ValidationError
	if errors.As(err, &vErr) {
		writeError(w, http.StatusBadRequest, "validation", "invalid request", vErr.Fields)
		return
	}

	var nf core.NotFoundError
	if errors.As(err, &nf) {
		writeError(w, http.StatusNotFound, "not_found", "resource not found", nil)
		return
	}

	writeError(w, http.StatusInternalServerError, "internal", "internal error", nil)
}

Here we follow an important contract rule: for 500-class responses we do not return err.Error() to the client. The client only needs “internal error,” and details go to the logs (we discussed logging in other parts of the course, but in the HTTP contract this rule is fixed especially strictly).

Finally, our helper response functions. They are fully HTTP-specific, so they live in the HTTP package.

package httpapi

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

And the error envelope:

package httpapi

type apiError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type errorEnvelope struct {
	Error apiError `json:"error"`
}
package httpapi

import (
	"encoding/json"
	"net/http"
)

func writeError(w http.ResponseWriter, status int, code, msg string, fields map[string]string) {
	writeJSON(w, status, errorEnvelope{
		Error: apiError{Code: code, Message: msg, Fields: fields},
	})
}

Yes, writeError depends on writeJSON. And yes, that is fine: inside the HTTP layer we build a “single response path” so we can test the contract consistently.

4. Testing: core without httptest, HTTP by contract

When core is extracted from HTTP, a very nice bonus appears: business tests no longer require simulating requests, URL, ResponseRecorder, and JSON decoding. We simply call service methods like ordinary functions. The test becomes shorter, faster, and clearer.

For example, let's test that an empty title yields a ValidationError with the title field. For the test we do not even need a real store, because validation triggers earlier. You could pass a nil store, but it is safer to make a stub.

package core_test

import (
	"context"
	"testing"

	"example.com/app/core"
)

type nopStore struct{}

func (nopStore) Create(ctx context.Context, title string) (core.Task, error) {
	return core.Task{ID: 1, Title: title}, nil
}
func (nopStore) GetByID(ctx context.Context, id int) (core.Task, error) { return core.Task{}, nil }
func (nopStore) MarkDone(ctx context.Context, id int) (core.Task, error) { return core.Task{}, nil }

func TestTaskService_Create_EmptyTitle(t *testing.T) {
	svc := core.NewTaskService(nopStore{})

	_, err := svc.Create(context.Background(), "   ")
	if err == nil {
		t.Fatalf("err=nil, want validation error")
	}
}

So far we only checked the fact of an error. Let's check the Fields structure via errors.As—again without HTTP.

package core_test

import (
	"context"
	"errors"
	"testing"

	"example.com/app/core"
)

func TestTaskService_Create_ValidationFields(t *testing.T) {
	svc := core.NewTaskService(nopStore{})

	_, err := svc.Create(context.Background(), "")
	var vErr core.ValidationError
	if !errors.As(err, &vErr) {
		t.Fatalf("err=%T, want ValidationError", err)
	}

	if vErr.Fields["title"] != "must not be empty" {
		t.Fatalf("fields.title=%q", vErr.Fields["title"])
	}
}

This is where the feeling usually comes: “Wow, I tested a business rule and did not need HTTP at all.” Yes, exactly. That is the goal of separation of responsibility.

And the HTTP layer in such an architecture is easier to test: we already know core classifies errors correctly, and now HTTP tests can focus on mapping “error → status/envelope,” not on validating every rule in every handler.

5. Final picture: data flow and two boundaries

It is useful to see the overall scheme once so your brain stops trying to “cram everything into the handler.” Imagine the request as a traveler who goes through customs twice: at the entrance (HTTP decode) and at the exit (HTTP encode). Everything between them is core.

flowchart TD
    A[HTTP Request] --> B[HTTP decode: JSON, path params]
    B --> C[core: validate + usecase]
    C --> D[core result or domain error]
    D --> E[HTTP map: error -> status + envelope]
    E --> F[HTTP Response JSON]

The point of this picture is not for you to memorize the arrows. The point is that domain-rule validation lives in core, and the response format lives in HTTP. Then changes do not smear: you changed the rule “title must be at least 3 characters”—you edit core and its tests. You changed the API contract (for example, the message for not_found)—you edit HTTP and its contract tests. These changes do not interfere with each other, and that is very calming.

6. Typical mistakes when separating core and HTTP

Mistake No. 1: dragging *http.Request into core “just to avoid passing parameters.”
It is tempting to do: “but everything is in the request anyway.” But this makes core dependent on transport. As soon as you want to call the same logic from a CLI or another adapter, you will realize you are carrying HTTP into the application's brain. Better pass only what core needs: id, title, ctx.

Mistake No. 2: duplicating validation both in the handler and in core.
Sometimes people do this: the handler checks title != "", and core also checks it. It feels “safer,” but in reality you get two sources of truth. A month later the checks drift apart, and the bug appears not because you “wrote bad code,” but because you have two versions of reality. Keep only transport checks in the HTTP layer (for example, “can we read the JSON at all”), and keep business validation in core.

Mistake No. 3: returning ready-made HTTP statuses or JSON structures from core.
Core should not say “return 404.” Core should say “not found.” The HTTP layer will decide that “not found” in the HTTP contract is 404 + {error:{code:"not_found",...}}. If you mix the levels, you cement transport into business logic.

Mistake No. 4: exposing err.Error() in 500 responses for “debugging convenience.”
This breaks the contract and security: internal details (paths, SQL queries, unexpected messages) leak outside. The correct approach is a stable client-facing message and details in logs. Separation of responsibility helps enforce this discipline: core can return the “real” error, but the HTTP layer must hide its text for 500.

Mistake No. 5: testing business rules only through HTTP tests.
You can, of course, verify everything via httptest.NewServer, but tests will be longer, slower, and more fragile. When core is separated, business rules are tested with regular unit tests without HTTP, and HTTP tests become a short contract check: status, headers, and the structure of the error envelope. In the end this means less code and more confidence.

1
Task
Go SELF, level 64, lesson 3
Locked
Core validation
Core validation
1
Task
Go SELF, level 64, lesson 3
Locked
Error mapping
Error mapping
1
Task
Go SELF, level 64, lesson 3
Locked
Task Service
Task Service
1
Task
Go SELF, level 64, lesson 3
Locked
HTTP adapter
HTTP adapter
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION