SoC — Separation of Concerns #

There’s one function that almost always appears in a project’s early codebase: the HTTP handler that does everything. Request parsing, input validation, database queries, business logic, response formatting, logging — all in one function, written sequentially from top to bottom. In the first sprint, this is normal and productive. But when features grow, when the same need appears in another endpoint, when unit tests must be written but can’t run without a database — that’s when the cost is felt. Separation of Concerns (SoC) attacks this problem at its root: every part of the system should focus on one aspect, and not meddle in the affairs of other aspects. Not just “more folders” or “more files” — but meaningful separation based on clear responsibility boundaries. This article covers what a concern means, how to distinguish SoC from SRP, step-by-step refactoring from a monolithic handler to layered architecture, SoC in Flutter frontends, SoC at the system level, and when separation becomes disproportionate.

What Is a “Concern”? #

A concern is a particular aspect or problem domain the system needs to handle. The key word is “aspect” — not “feature”, not “module”, not “class”. A feature can have many separate concerns.

In backend applications, common concerns include:

Concern                    Its responsibility
─────────────────────────  ──────────────────────────────────────────
HTTP transport             Request parsing, response formatting, HTTP status codes
Input validation           Format, completeness, data types of external input
Business logic             Domain rules: who can do what, how calculations work
Data access                Queries, inserts, updates to a database or storage
Notifications              Email, push notifications, SMS
Authentication             Identity verification (who are you?)
Authorization              Access rights verification (what are you allowed to do?)
Logging & observability    Structured logs, metrics, traces
Configuration              Reading and distributing environment variables

Problems arise not when these concerns exist — but when two or more different concerns are mixed in one place. When an HTTP handler knows how to query the database, it has two concerns at once. When a service knows how to format JSON responses, its responsibility boundary has already leaked.


SoC vs SRP — Different Levels #

SoC and SRP are often considered the same because both talk about separating responsibilities. But they operate at different levels:

SRP (Single Responsibility Principle):
  Level  : module, struct, class
  Question: "Does this struct/class have more than one reason to change?"
  Focus  : implementation granularity

SoC (Separation of Concerns):
  Level  : system, architecture, layers
  Question: "Are the different aspects of this system already separated?"
  Focus  : structure and boundaries between system parts

Analogy:
  SRP = each worker has one role (the cashier only at the register, the cook only in the kitchen)
  SoC = each department has its own domain (kitchen, register, warehouse are separate)
  Both are needed — SoC at the architecture level, SRP at the implementation level

SoC is the broader principle. Layered Architecture, Clean Architecture, MVC, and Microservices are all SoC implementations at different levels. SRP is the way to apply SoC within each layer.

flowchart TD
    SOC["Separation of Concerns\n(architecture & system level)"]
    LA["Layered Architecture\n(HTTP → Service → Repository)"]
    MICRO["Microservices\n(Auth Service, Order Service, ...)"]
    MVC["MVC / MVP / MVVM\n(Model, View, Controller)"]
    SRP2["SRP in every layer\n(implementation granularity)"]

    SOC -->|"architecture-level implementation"| LA
    SOC -->|"system-level implementation"| MICRO
    SOC -->|"frontend implementation"| MVC
    LA & MICRO & MVC -->|"inside every component"| SRP2

    style SOC fill:#4C9BE8,color:#fff
    style SRP2 fill:#5CB85C,color:#fff

The Real Problem Without SoC #

Before looking at the solution, it’s important to understand the concrete costs that appear when concerns are mixed — not just “the code looks bad”:

1. Business logic can’t be unit tested. If business logic lives inside an HTTP handler that queries the database directly, the only way to test it is by running the HTTP server and database together. Tests accidentally become integration tests — slow, fragile, and hard to debug.

2. The same logic can’t be reused. When the same need arises from different contexts — for example, logic that must be called from both an HTTP handler and a background worker — the code must be duplicated because it’s already tied to HTTP concerns. This is a DRY violation triggered by an SoC violation.

3. A change in one aspect breaks other aspects. Switching the database engine from MySQL to PostgreSQL should only affect the data access layer. If SQL queries are scattered across handlers and services, that change must be made in many places — and every place is a risk.

4. Inflexible deployment and scaling. When business logic, HTTP handling, and database access are all in one inseparable binary, there’s no option to scale out only the bottleneck part.


From Monolithic Handler to Layered Architecture #

Step-by-step refactoring from a handler mixing all concerns to a layered architecture is the most direct demonstration of SoC.

Starting point: the handler that does everything

// ANTI-PATTERN: all concerns in one function
// HTTP, validation, business logic, database, response — all here
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // === Concern 1: HTTP parsing ===
    var req struct {
        UserID string      `json:"user_id"`
        Items  []struct {
            ProductID string `json:"product_id"`
            Quantity  int    `json:"quantity"`
        } `json:"items"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    // === Concern 2: Validation ===
    if req.UserID == "" {
        http.Error(w, "user_id is required", http.StatusBadRequest)
        return
    }
    if len(req.Items) == 0 {
        http.Error(w, "at least one item required", http.StatusBadRequest)
        return
    }

    // === Concern 3: Check user (business rule) — direct query ===
    var userActive bool
    db.QueryRow("SELECT is_active FROM users WHERE id = ?", req.UserID).Scan(&userActive)
    if !userActive {
        http.Error(w, "user account is not active", http.StatusForbidden)
        return
    }

    // === Concern 4: Calculate total (business logic) ===
    total := 0.0
    for _, item := range req.Items {
        var price float64
        db.QueryRow("SELECT price FROM products WHERE id = ?", item.ProductID).Scan(&price)
        total += price * float64(item.Quantity)
    }

    // === Concern 5: Save the order (data access) ===
    result, err := db.Exec(
        "INSERT INTO orders (user_id, total, status) VALUES (?, ?, 'pending')",
        req.UserID, total,
    )
    if err != nil {
        http.Error(w, "failed to create order", http.StatusInternalServerError)
        return
    }
    orderID, _ := result.LastInsertId()

    // === Concern 6: Format the response ===
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "order_id": orderID,
        "total":    total,
        "status":   "pending",
    })
}
// Testing this function requires: an HTTP server + a populated database
// The "calculate total" business logic can't be tested independently
// The same logic can't be reused from a background worker

Step 1: Domain Layer — entities and core business rules

// internal/order/domain.go
package order

import (
    "errors"
    "time"
)

// Order is a domain entity — doesn't know HTTP, doesn't know SQL
type Order struct {
    ID        string
    UserID    string
    Items     []OrderItem
    Status    Status
    CreatedAt time.Time
}

type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice float64
}

type Status string

const (
    StatusPending   Status = "pending"
    StatusConfirmed Status = "confirmed"
    StatusCancelled Status = "cancelled"
)

// Business rules attach to the domain, not to handlers or services
func (o Order) Total() float64 {
    total := 0.0
    for _, item := range o.Items {
        total += item.UnitPrice * float64(item.Quantity)
    }
    return total
}

func (o Order) IsEmpty() bool {
    return len(o.Items) == 0
}

func (o Order) Validate() error {
    if o.UserID == "" {
        return errors.New("order must belong to a user")
    }
    if o.IsEmpty() {
        return errors.New("order must have at least one item")
    }
    return nil
}

// Concern: data representation and core domain rules
// Doesn't know: how data is stored, how requests arrive, how responses are formatted

Step 2: Repository Layer — the data access concern

// internal/order/repository.go
package order

import (
    "context"
    "database/sql"
    "fmt"
)

// OrderRepository is an interface — depends on abstraction (DIP)
type OrderRepository interface {
    Save(ctx context.Context, order Order) (string, error)
    FindByID(ctx context.Context, id string) (*Order, error)
    FindByUserID(ctx context.Context, userID string) ([]Order, error)
}

type postgresOrderRepository struct {
    db *sql.DB
}

func NewPostgresOrderRepository(db *sql.DB) OrderRepository {
    return &postgresOrderRepository{db: db}
}

func (r *postgresOrderRepository) Save(ctx context.Context, order Order) (string, error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return "", fmt.Errorf("save order: begin tx: %w", err)
    }
    defer tx.Rollback()

    var orderID string
    err = tx.QueryRowContext(ctx,
        `INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING id`,
        order.UserID, order.Total(), string(order.Status),
    ).Scan(&orderID)
    if err != nil {
        return "", fmt.Errorf("save order: insert: %w", err)
    }

    for _, item := range order.Items {
        _, err = tx.ExecContext(ctx,
            `INSERT INTO order_items (order_id, product_id, quantity, unit_price)
             VALUES ($1, $2, $3, $4)`,
            orderID, item.ProductID, item.Quantity, item.UnitPrice,
        )
        if err != nil {
            return "", fmt.Errorf("save order: insert item: %w", err)
        }
    }

    if err := tx.Commit(); err != nil {
        return "", fmt.Errorf("save order: commit: %w", err)
    }
    return orderID, nil
}

// Concern: how data is stored and retrieved from the database
// Doesn't know: HTTP, business rules, how input is validated

Step 3: Service Layer — the business logic concern

// internal/order/service.go
package order

import (
    "context"
    "fmt"
)

type UserChecker interface {
    IsActive(ctx context.Context, userID string) (bool, error)
}

type ProductPricer interface {
    GetPrice(ctx context.Context, productID string) (float64, error)
}

type OrderService struct {
    repo    OrderRepository
    users   UserChecker
    pricing ProductPricer
}

func NewOrderService(
    repo OrderRepository,
    users UserChecker,
    pricing ProductPricer,
) *OrderService {
    return &OrderService{repo: repo, users: users, pricing: pricing}
}

type CreateOrderRequest struct {
    UserID string
    Items  []CreateOrderItem
}

type CreateOrderItem struct {
    ProductID string
    Quantity  int
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (string, error) {
    // Business rule: check the user is active
    active, err := s.users.IsActive(ctx, req.UserID)
    if err != nil {
        return "", fmt.Errorf("createOrder: check user: %w", err)
    }
    if !active {
        return "", ErrUserNotActive
    }

    // Build the domain entity with prices from the product service
    items := make([]OrderItem, 0, len(req.Items))
    for _, reqItem := range req.Items {
        price, err := s.pricing.GetPrice(ctx, reqItem.ProductID)
        if err != nil {
            return "", fmt.Errorf("createOrder: get price %s: %w", reqItem.ProductID, err)
        }
        items = append(items, OrderItem{
            ProductID: reqItem.ProductID,
            Quantity:  reqItem.Quantity,
            UnitPrice: price,
        })
    }

    order := Order{
        UserID: req.UserID,
        Items:  items,
        Status: StatusPending,
    }

    // Validate the domain entity
    if err := order.Validate(); err != nil {
        return "", fmt.Errorf("createOrder: validate: %w", err)
    }

    // Save through the repository
    orderID, err := s.repo.Save(ctx, order)
    if err != nil {
        return "", fmt.Errorf("createOrder: save: %w", err)
    }

    return orderID, nil
}

// Concern: business logic orchestration
// Doesn't know: HTTP, SQL queries, JSON formatting

Step 4: Handler Layer — the HTTP transport concern

// internal/api/order_handler.go
package api

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

    "github.com/example/app/internal/order"
)

type OrderHandler struct {
    service *order.OrderService
}

func NewOrderHandler(service *order.OrderService) *OrderHandler {
    return &OrderHandler{service: service}
}

type createOrderRequest struct {
    UserID string `json:"user_id"`
    Items  []struct {
        ProductID string `json:"product_id"`
        Quantity  int    `json:"quantity"`
    } `json:"items"`
}

type createOrderResponse struct {
    OrderID string `json:"order_id"`
    Message string `json:"message"`
}

func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    // HTTP concern: parse the request
    var req createOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    // Translate the HTTP request into a service request
    items := make([]order.CreateOrderItem, len(req.Items))
    for i, item := range req.Items {
        items[i] = order.CreateOrderItem{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
        }
    }

    orderID, err := h.service.CreateOrder(r.Context(), order.CreateOrderRequest{
        UserID: req.UserID,
        Items:  items,
    })
    if err != nil {
        // Translate domain errors into HTTP statuses
        switch {
        case errors.Is(err, order.ErrUserNotActive):
            writeError(w, http.StatusForbidden, err.Error())
        case errors.Is(err, order.ErrValidation):
            writeError(w, http.StatusUnprocessableEntity, err.Error())
        default:
            writeError(w, http.StatusInternalServerError, "internal server error")
        }
        return
    }

    // HTTP concern: format the response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(createOrderResponse{
        OrderID: orderID,
        Message: "order created successfully",
    })
}

// Concern: HTTP transport — parse requests, translate errors, format responses
// Doesn't know: SQL, price calculation business logic, domain rules
flowchart TD
    HTTP["HTTP Request"]
    H["Handler\n(HTTP concern:\nparse, format validation,\nformat response)"]
    SVC["Service\n(Business logic concern:\norchestration, domain rules,\nbusiness validation)"]
    DOM["Domain\n(Entity concern:\ndata structure, core rules,\nbusiness invariants)"]
    REPO["Repository\n(Data access concern:\nSQL, transactions,\nmapping to domain)"]
    DB["(Database)"]

    HTTP --> H
    H -->|"CreateOrderRequest"| SVC
    SVC -->|"domain entity"| DOM
    SVC -->|"Save(ctx, order)"| REPO
    REPO --> DB
    DB --> REPO
    REPO -->|"orderID"| SVC
    SVC -->|"orderID, error"| H
    H -->|"HTTP Response"| HTTP

    style H fill:#F0AD4E,color:#fff
    style SVC fill:#4C9BE8,color:#fff
    style DOM fill:#9B59B6,color:#fff
    style REPO fill:#5CB85C,color:#fff

The result: business logic in OrderService can be tested without an HTTP server or a database — inject fake UserChecker, ProductPricer, and OrderRepository. The handler can be tested with a mock service. The repository can be tested independently with integration tests that only touch the database.


SoC at the Function Level — Middleware #

SoC isn’t only about layers — it also applies within a layer itself, for example in HTTP middleware:

// ANTI-PATTERN: authentication, logging, and rate limiting all in the handler
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // === Concern: authentication ===
    token := r.Header.Get("Authorization")
    userID, err := validateJWT(token)
    if err != nil {
        http.Error(w, "unauthorized", 401)
        return
    }

    // === Concern: logging ===
    start := time.Now()
    defer func() {
        log.Printf("POST /orders user=%s duration=%v", userID, time.Since(start))
    }()

    // === Concern: rate limiting ===
    if !rateLimiter.Allow(userID) {
        http.Error(w, "too many requests", 429)
        return
    }

    // === Concern: business logic ===
    // ... only starts here
}

// CORRECT: each concern has its own middleware
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        userID, err := validateJWT(token)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), contextKeyUserID, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rec, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rec.status,
            "duration", time.Since(start),
        )
    })
}

func RateLimitMiddleware(limiter *rate.Limiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "too many requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// The handler now has only one concern: business logic for this endpoint
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
    userID := r.Context().Value(contextKeyUserID).(string)
    // ... straight to business logic
}

// The middleware stack is defined in one place
router.Use(LoggingMiddleware)
router.Use(AuthMiddleware)
router.Use(RateLimitMiddleware(limiter))
router.POST("/orders", handler.Create)

SoC in the Frontend — Dart/Flutter #

In Flutter, SoC is most often violated by putting business logic inside widgets. A widget with HTTP calls or business calculations directly in it is a sign of mixed concerns.

// ANTI-PATTERN: all concerns in one widget
class OrderListActivity extends AppCompatActivity {
    private final List<Order> orders = new ArrayList<>();
    private boolean loading = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        loadOrders();
    }

    void loadOrders() {
        loading = true;
        new Thread(() -> {
            try {
                // === Data access concern inside the widget ===
                HttpURLConnection conn = (HttpURLConnection)
                        new URL("https://api.example.com/orders").openConnection();
                conn.setRequestProperty("Authorization", "Bearer " + authToken);
                String body = new String(conn.getInputStream().readAllBytes());
                List<Order> data = Order.parseAll(body);

                // === Business logic inside the widget ===
                List<Order> filtered = new ArrayList<>();
                for (Order o : data) {
                    if (o.total > 0) { // business rule in a widget
                        filtered.add(o);
                    }
                }

                runOnUiThread(() -> {
                    orders.clear();
                    orders.addAll(filtered);
                    loading = false;
                    adapter.notifyDataSetChanged();
                });
            } catch (Exception e) {
                // === Error handling inside the widget ===
                runOnUiThread(() -> loading = false);
            }
        }).start();
    }

    // Can't unit test the filtering logic without rendering the widget
}

// CORRECT: each concern separated into its place

// Data access concern
interface OrderRepository {
    List<Order> getOrders();
}

class HttpOrderRepository implements OrderRepository {
    private final String baseUrl;
    private final String token;

    HttpOrderRepository(String baseUrl, String token) {
        this.baseUrl = baseUrl;
        this.token = token;
    }

    @Override
    public List<Order> getOrders() {
        HttpURLConnection conn = (HttpURLConnection) new URL(baseUrl + "/orders").openConnection();
        conn.setRequestProperty("Authorization", "Bearer " + token);
        if (conn.getResponseCode() != 200) {
            throw new ApiException(conn.getResponseCode());
        }
        String body = new String(conn.getInputStream().readAllBytes());
        return Order.parseAll(body);
    }
}

// Business logic concern
class OrderViewModel {
    private final OrderRepository repository;
    private final List<Order> orders = new ArrayList<>();
    private boolean loading = false;
    private String error;

    OrderViewModel(OrderRepository repository) {
        this.repository = repository;
    }

    void loadOrders() {
        loading = true;
        error = null;
        notifyListeners();

        new Thread(() -> {
            try {
                List<Order> all = repository.getOrders();
                // Business rules live in the ViewModel, testable without widgets
                List<Order> filtered = new ArrayList<>();
                for (Order o : all) {
                    if (o.total > 0) {
                        filtered.add(o);
                    }
                }
                orders.clear();
                orders.addAll(filtered);
            } catch (Exception e) {
                error = "Failed to load orders";
            } finally {
                loading = false;
                notifyListeners();
            }
        }).start();
    }
}

// UI concern
class OrderListActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        OrderViewModel vm = new OrderViewModel(new HttpOrderRepository(baseUrl, token));
        vm.loadOrders();
        // The activity only renders state from the ViewModel
        if (vm.isLoading()) {
            showLoadingIndicator();
        } else if (vm.getError() != null) {
            showError(vm.getError());
        } else {
            showOrders(vm.getOrders());
        }
    }
}
// The business rule (total > 0 filter) now lives in the ViewModel
// Testable with a FakeOrderRepository without rendering any widget

SoC at the System Architecture Level #

At the larger level, SoC is applied as separation between services or subsystems. Each service is responsible for one bounded context — and doesn’t meddle in other domains’ affairs.

flowchart TD
    GW["API Gateway\n(routing, rate limit, auth token validation)"]
    AUTH["Auth Service\n(identity, token issuance, session)"]
    USER["User Service\n(profile, preferences, status)"]
    ORDER["Order Service\n(order creation, order status)"]
    INVENTORY["Inventory Service\n(stock, reservations)"]
    NOTIF["Notification Service\n(email, push, SMS)"]
    PAYMENT["Payment Service\n(charge, refund, webhook)"]

    GW --> AUTH
    GW --> USER
    GW --> ORDER
    ORDER -->|"HTTP: reserve stock"| INVENTORY
    ORDER -->|"HTTP: initiate payment"| PAYMENT
    ORDER -->|"event: order_created"| NOTIF
    USER -->|"event: user_registered"| NOTIF

    style GW fill:#F0AD4E,color:#fff
    style AUTH fill:#4C9BE8,color:#fff
    style NOTIF fill:#9B59B6,color:#fff

Each service only knows about its own concern. The Notification Service doesn’t know how to create orders — it only knows how to send notifications based on events it receives. The Order Service doesn’t know how to send email — it just publishes an order_created event and lets the right subscriber handle it.


Common Mistakes When Applying SoC #

1. THINKING SoC = MANY FOLDERS
   Creating handler/, service/, repository/ folders doesn't automatically
   mean SoC is satisfied if business logic still leaks into handlers or
   SQL queries are scattered across services.
   → SoC is about responsibility boundaries, not directory structure

2. BUSINESS LOGIC LEAKING INTO HANDLERS
   A handler doing more than request parsing and response formatting
   is a sign the business concern leaks into the HTTP concern.
   → Move all "if user is gold, apply 15% discount" into the service

3. REPOSITORIES CONTAINING BUSINESS LOGIC
   A repository doing price calculations or making business decisions
   is a sign the data access concern is mixed with the business concern.
   → Repositories only know how to store and retrieve data

4. OVER-LAYERING WITHOUT ADDED VALUE
   A UseCase that only forwards calls to a Repository without any logic
   is a layer that adds no SoC — only indirection.
   → Add a layer only when there's a genuinely different concern

5. DOMAIN ENTITIES THAT KNOW HOW THEY'RE STORED
   A domain entity with a Save() method or importing a database driver
   is a sign the domain concern and data access concern are mixed.
   → Domain entities must not know any infrastructure
SoC should be proportional to complexity. For small scripts or prototypes, a full layered architecture is over-engineering. For applications that will live long and be worked on by many people, SoC is an investment that pays off every time there’s a change. Start simple, add layers when business complexity genuinely requires them.

When SoC Matters Most #

SOC PROVIDES THE GREATEST VALUE WHEN:
  ✓ Large teams — engineers can work in parallel on different concerns
    without blocking each other (frontend vs backend, service A vs service B)
  ✓ Fast business changes — separated business logic can be changed
    without worrying about breaking HTTP handling or database access
  ✓ Testing is a priority — unit testing business logic without infrastructure
    is only possible when concerns are properly separated
  ✓ Multiple delivery channels — the same logic needs to be callable
    from HTTP handlers, gRPC servers, and background workers
  ✓ Long-lived systems — concern separation costs are repaid repeatedly
    every time there's a future change

RECONSIDER WHEN:
  ✗ One-off scripts or small internal tools
  ✗ Prototypes that won't become production code
  ✗ Very small teams (1–2 people) with limited scope
  → Start simple, refactor toward SoC as the team and complexity grow

Anti-Patterns at a Glance #

// ✗ A handler that knows SQL
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    // ... parse request ...
    db.Exec("INSERT INTO orders ...") // HTTP concern knows SQL
}

// ✗ A service that knows JSON
func (s *OrderService) CreateOrder(req *http.Request) {
    var body struct { ... }
    json.NewDecoder(req.Body).Decode(&body) // business concern knows HTTP
}

// ✗ A repository with business logic
func (r *OrderRepository) SaveWithDiscount(order Order) error {
    if order.UserTier == "gold" {
        order.Total *= 0.85 // data access concern knows business rules
    }
    r.db.Exec("INSERT INTO orders ...", order.Total)
    return nil
}

// ✗ A domain entity that knows how it's stored
type Order struct { ... }
func (o *Order) Save(db *sql.DB) error { // domain entity knows SQL
    db.Exec("INSERT INTO orders ...")
    return nil
}

// ✗ A Flutter widget making direct HTTP calls
class OrderWidget extends StatefulWidget {
    void _load() async {
        final resp = await http.get(Uri.parse("https://api.example.com/orders"))
        // UI concern knows network details
    }
}

SoC Review Checklist #

HANDLER LAYER (HTTP):
  □ Handlers have no SQL queries or direct database access
  □ Handlers do no business calculations (prices, discounts, taxes)
  □ Handlers only parse requests, call services, format responses
  □ Authentication and logging live in middleware, not handlers

SERVICE LAYER (BUSINESS LOGIC):
  □ Services don't know how to parse HTTP requests or format JSON responses
  □ Services have no direct SQL queries
  □ Business rules (discounts, business validation, status transitions) live in services or the domain
  □ Services speak the domain language, not HTTP or SQL language

REPOSITORY LAYER (DATA ACCESS):
  □ Repositories do no business calculations
  □ Repositories make no business flow decisions
  □ Repositories only know: how to save, retrieve, and update data

DOMAIN ENTITIES:
  □ Domain entities don't import database drivers or HTTP packages
  □ Business invariants (Validate(), Total(), IsEligible()) exist as methods
  □ Domain entities instantiable and testable without any infrastructure

FRONTEND (FLUTTER):
  □ Widgets have no direct HTTP calls
  □ Business logic lives in ViewModels/Controllers, testable without rendering widgets
  □ The repository layer handles networking and parsing

Summary #

  • SoC isn’t about the number of folders — it’s about clear responsibility boundaries. A tidy directory structure doesn’t guarantee SoC if business logic still lives in handlers or SQL queries are scattered across services.
  • SoC vs SRP: SoC at the architecture level (layers, services, subsystems); SRP at the implementation level (structs, classes, functions). They complement each other — SoC defines boundaries, SRP maintains cohesion within those boundaries.
  • Seven main concerns needing separation: HTTP transport, input validation, business logic, data access, notifications, authentication/authorization, and observability. Every concern has a right place — and must not leak into other places.
  • Step-by-step refactoring: Domain entity (core rules) → Repository (SQL) → Service (business orchestration) → Handler (HTTP). Each layer only knows the layer below it through interfaces — never skipping layers.
  • Middleware is SoC at the function level: authentication, logging, and rate limiting each become independent middleware — the handler has only one concern: business logic for that endpoint.
  • In Flutter: widgets only handle UI, ViewModels/Controllers handle business logic, repositories handle data access. Business rules in ViewModels are testable without rendering any widget.
  • At the system level: each service is the SSOT for its domain and doesn’t meddle in other domains. Communication between concerns happens through APIs or events, not direct access to another service’s database.
  • Common mistakes: business logic leaking into handlers, SQL in services, business calculations in repositories, domain entities knowing infrastructure, and Flutter widgets making HTTP calls.
  • SoC should be proportional to complexity: for prototypes and small scripts, start simple. Add layers when the team and business complexity genuinely require it — not before.

← Previous: SSOT   Next: SPoF →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact