Files
kurious/docs/ARCHITECTURE.md

18 KiB

Architecture

Patterns

Hexagonal Architecture (Ports & Adapters)

The project follows hexagonal architecture — business logic lives at the center and all I/O (databases, HTTP, external APIs) is pushed to the periphery through interfaces. The domain never imports infrastructure packages.

┌─────────────────────────────────────────────────────┐
│  Ports (delivery mechanisms)                         │
│    ports/http/       — HTTP server (gorilla/mux)     │
│    ports/background/ — Cron jobs (robfig/cron)       │
└──────────────────────┬──────────────────────────────┘
                       │ depends on
┌──────────────────────▼──────────────────────────────┐
│  Application layer (use cases)                      │
│    app/command/   — write-side handlers             │
│    app/query/     — read-side handlers              │
│    decorator/     — handler interfaces + logging     │
└──────────────────────┬──────────────────────────────┘
                       │ depends on
┌──────────────────────▼──────────────────────────────┐
│  Domain (pure business rules)                        │
│    domain/  — entities (Course, Organization, ...)    │
│    domain/  — repository interfaces (ports)          │
│    domain/  — mapper interface (CourseMapper)        │
└──────────────────────┬──────────────────────────────┘
                       │ implemented by
┌──────────────────────▼──────────────────────────────┐
│  Adapters (infrastructure)                           │
│    adapters/sqlite_*_repository.go — SQLite repos     │
│    adapters/memory_mapper.go      — in-memory mapper │
│    adapters/ydb_*                 — legacy YDB stubs │
│    adapters/not_implemented.go    — sentinel stubs  │
└─────────────────────────────────────────────────────┘

CQRS (Command-Query Responsibility Segregation)

The application layer splits all use cases into commands (writes) and queries (reads), each with its own handler type:

  • Commands implement decorator.CommandHandler[T]Handle(ctx, params) error
  • Queries implement decorator.QueryHandler[Q, U]Handle(ctx, query) (result, error)

Both are aggregated into app.Application{Commands, Queries} and exposed through service.Application.

Decorator Pattern

Every handler is wrapped with a logging + tracing decorator at construction time (decorator.ApplyCommandDecorators / decorator.AddQueryDecorators). The decorator:

  1. Creates an OpenTelemetry span ("command <Name>" / "query <Name>")
  2. Logs structured fields (handler name, serialized args)
  3. Measures elapsed time
  4. Records errors on the span
  5. Delegates to the base handler

The decorator is applied inside New*Handler() constructors — callers never see the raw handler.

Composition Root

service.NewApplication is the single place where all wiring happens: it selects the DB engine, creates connections, instantiates adapters, registers handlers on the Application struct, and injects the CourseMapper. Binaries in cmd/ call this function.

Repository Interface Segregation

Each aggregate has its own repository interface defined in domain/:

Interface Methods (key)
CourseRepository List, Get, GetByExternalID, Create, CreateBatch, Delete, UpdateCourseDescription, ListStatistics
OrganizationRepository List, Get, ListStats, Create, Delete
LearningCategoryRepository List, Get, Upsert

Adapters implement these interfaces. Unimplemented methods use ErrNotImplemented sentinel stubs.

External Client Abstraction

The sravni.ru HTTP client (common/client/sravni/Client interface) is abstracted behind an interface with a noop.Client fallback. The background sync handler depends on the interface, not the concrete HTTP implementation.

Logic Placement

Concern Where it lives
Business entities internal/kurious/domain/
Repository interfaces (ports) internal/kurious/domain/repository.go
Mapper interface internal/kurious/domain/mapper.go
Write-side use cases internal/kurious/app/command/
Read-side use cases internal/kurious/app/query/
Handler interfaces + decorators internal/common/decorator/
Repository implementations internal/kurious/adapters/
HTTP routing + handlers internal/kurious/ports/http/
Templ templates internal/kurious/ports/http/bootstrap/*.templ
Background jobs internal/kurious/ports/background/
Composition root internal/kurious/service/service.go
HTTP client (sravni.ru) internal/common/client/sravni/
Config structs internal/common/config/
Error sentinels internal/common/errors/
Logging helpers internal/common/xcontext/, common/xlog/
Utility functions internal/common/xslices/, common/nullable/
ID generation internal/common/generator/
Entry points (binaries) cmd/kuriweb/, cmd/background/, cmd/dev/
SQL migrations migrations/sqlite/ (embedded via go:embed)
Static web assets assets/kurious/static/ (embedded via go:embed)
Public package pkg/xdefault/

Layers

1. Domain Layer (internal/kurious/domain/)

Pure Go types — no framework imports, no I/O. Contains:

  • Entities: Course, Organization, LearningCategory — structs with fields and no behavior beyond data representation.
  • Repository interfaces: CourseRepository, OrganizationRepository, LearningCategoryRepository — the ports that adapters must implement.
  • Mapper interface: CourseMapper — maps external dictionary IDs to human-readable names; implemented by adapters/memory_mapper.go.
  • Value objects: SourceType, ListCoursesParams, CreateCourseParams, etc. — parameter types used by repository interfaces.

2. Application Layer (internal/kurious/app/)

Orchestrates domain objects through repository ports:

  • app.goApplication{Commands, Queries} aggregate struct. Groups all handlers into a single type.
  • command/ — Write handlers: CreateCourse, CreateCourses, DeleteCourse, UpdateCourseDescription, CreateOrganization. Each is a struct holding repository dependencies, with a New*Handler() constructor that wraps the base with decorators.
  • query/ — Read handlers: GetCourse, ListCourses, ListLearningTypes, ListCourseThematics, ListCoursesStats, ListOrganizations, ListOrganizationsStats, GetOrganization. Some queries (e.g. ListCourses) implement cursor-based pagination with configurable batch sizes.

3. Common Infrastructure (internal/common/)

Shared packages used across the application:

  • decorator/ — Generic CommandHandler[T] and QueryHandler[Q, U] interfaces + logging/tracing decorator implementations. Uses OpenTelemetry spans and structured slog logging.
  • client/sravni/ — HTTP client for sravni.ru with Client interface, noop.Client fallback, entities, and helpers. Uses go-resty internally.
  • config/ — Typed config structs: Log, HTTP, Sqlite, Trace, YDB, Duration.
  • errors/SimpleError, ValidationError, sentinel errors (ErrNotFound, ErrNotImplemented). Mapped to HTTP status codes in ports/http.
  • xslices/ — Generic slice utilities: Map, Filter, ForEach, LRU.
  • nullable/ — Generic Value[T] for nullable fields (wraps a value + valid flag).
  • xcontext/ — Context helpers for propagating request-scoped log fields.
  • xlog/ — Adapters bridging slog to cron logger interface.
  • generator/ — ID generators (RandomInt64ID).

4. Adapters Layer (internal/kurious/adapters/)

Concrete implementations of domain interfaces:

  • sqlite_*_repository.go — SQLite-backed implementations using sqlx named queries. Each defines a row type with AsDomain() for entity mapping.
  • sqlite_connection.go — Connection factory; applies embedded migrations, returns ready-to-use repository instances.
  • memory_mapper.go — In-memory CourseMapper implementation; loads dictionary counts from the course repository, maps IDs ↔ names.
  • not_implemented.go — Sentinel adapter types returning ErrNotImplemented for methods not yet wired up.
  • ydb_* — Legacy YDB adapter stubs (engine no longer supported at runtime).

All DB operations create OpenTelemetry spans with db.* semantic attributes.

5. Ports Layer (internal/kurious/ports/)

Delivery mechanisms that drive the application:

  • http/ — HTTP server using gorilla/mux. Server struct holds service.Application and delegates to handlers. course.go contains the main List and Index handlers that call query handlers, map results to templ params, and render HTML.
  • http/bootstrap/ — Templ (.templ) template files compiled to Go. Defines view parameter structs and HTML rendering.
  • background/BackgroundProcess wrapping robfig/cron. Registers scheduled handlers like SyncSravniHandler.
  • background.go — Cron scheduler with job registration, shutdown, and next-run stats.
  • services.goServices{HTTP, Background} aggregate — the outermost composition point.

6. Service Layer (internal/kurious/service/)

The composition root:

  • service.goNewApplication() selects DB engine, creates connections, builds adapter instances, constructs all command/query handlers (with logging decorators), and assembles the Application struct. Also owns Close() for resource cleanup.

7. Entry Points (cmd/)

Three binaries, each in its own package:

Binary Location Purpose
kuriousweb cmd/kuriweb/ HTTP web server — starts server + optional background process
kuriousbg cmd/background/ Background sync process — runs cron scheduler only
sravnicli cmd/dev/sravnicli/ Developer CLI for inspecting sravni.ru data

Each binary reads a JSON config file, constructs service.Application via service.NewApplication, and starts its delivery mechanism.

Structure

.
├── cmd/                                # Entry points
│   ├── kuriweb/                        #   HTTP server binary
│   ├── background/                     #   Background sync binary
│   └── dev/sravnicli/                   #   Developer CLI
│
├── internal/
│   ├── kurious/                        # Application core (hexagon)
│   │   ├── domain/                     #   Entities + repository ports
│   │   │   ├── course.go               #     Course entity
│   │   │   ├── organization.go         #     Organization entity
│   │   │   ├── category.go             #     LearningCategory entity
│   │   │   ├── kurious.go              #     SourceType enum, shared domain types
│   │   │   ├── mapper.go               #     CourseMapper interface
│   │   │   └── repository.go           #     Repository interfaces + params
│   │   │
│   │   ├── app/                        #   Application layer (use cases)
│   │   │   ├── app.go                  #     Application{Commands, Queries}
│   │   │   ├── command/                #     Write-side handlers
│   │   │   │   ├── createcourse.go     #       CreateCourse, CreateCourses
│   │   │   │   ├── createorganization.go
│   │   │   │   ├── deletecourse.go
│   │   │   │   └── updatecoursedescription.go
│   │   │   └── query/                  #     Read-side handlers
│   │   │       ├── getcourse.go
│   │   │       ├── getorganization.go
│   │   │       ├── listcourses.go
│   │   │       ├── listcoursesstats.go
│   │   │       ├── listcoursethematics.go
│   │   │       ├── listlearningtypes.go
│   │   │       ├── listorganizations.go
│   │   │       └── listorganizationstats.go
│   │   │
│   │   ├── adapters/                   #   Repository implementations
│   │   │   ├── sqlite_course_repository.go
│   │   │   ├── sqlite_organization_repository.go
│   │   │   ├── sqlite_learning_category_repository.go
│   │   │   ├── sqlite_connection.go    #     Connection factory + migrations
│   │   │   ├── memory_mapper.go        #     In-memory CourseMapper
│   │   │   ├── not_implemented.go      #     ErrNotImplemented stubs
│   │   │   ├── adapters.go             #     Shared DB helpers, tracing
│   │   │   └── ydb_course_repository.go #   Legacy YDB stub
│   │   │
│   │   ├── ports/                      #   Delivery mechanisms
│   │   │   ├── http/                   #     HTTP server
│   │   │   │   ├── server.go           #       Server, error handling, pagination
│   │   │   │   ├── course.go           #       Course list/index handlers
│   │   │   │   └── bootstrap/          #       Templ templates + params
│   │   │   ├── background/             #     Cron job handlers
│   │   │   │   └── synchandler.go      #       Sravni sync handler
│   │   │   ├── background.go           #     Cron scheduler
│   │   │   └── services.go             #     Services aggregate
│   │   │
│   │   └── service/                    #   Composition root
│   │       └── service.go              #     NewApplication wiring
│   │
│   └── common/                         # Shared infrastructure
│       ├── decorator/                  #   Handler interfaces + logging decorators
│       ├── client/sravni/             #   Sravni.ru HTTP client
│       ├── config/                     #   Config structs
│       ├── errors/                     #   Error types + sentinels
│       ├── generator/                  #   ID generation
│       ├── nullable/                   #   Generic nullable Value[T]
│       ├── xcontext/                   #   Context log helpers
│       ├── xlog/                       #   Logger adapters
│       └── xslices/                    #   Slice utilities
│
├── pkg/xdefault/                        # Public package (WithFallback helper)
├── migrations/sqlite/                  # SQL migrations (go:embed)
├── assets/kurious/static/               # Static assets (go:embed)
├── docs/                                # Documentation
│   └── ARCHITECTURE.md                  #   This file
└── htmlexamples/                        # Templ prototyping

Data Flow

HTTP Request (read path)

HTTP request
  → gorilla/mux router (ports/http/server.go)
    → handler method (ports/http/course.go)
      → parse query params + pagination
        → app.Queries.ListCourses.Handle()     [application layer]
          → decorator: create span, log args    [decorator]
            → courseHandler.Handle()            [application handler]
              → domain.CourseRepository.List() [domain port]
                → sqlite_course_repository      [adapter]
                  → sqlx named query → SQLite   [infrastructure]
          ← domain.ListCoursesResult
      ← result → map to templ params
  ← templ.Render(ctx, w) → HTML response

Background Sync (write path)

cron tick
  → BackgroundProcess scheduler (ports/background.go)
    → syncSravniHandler.Handle()                  [port handler]
      → fill caches (known external IDs, orgs)
      → sravni.Client.GetMainPageState()          [external API]
      → for each learning type:
          → client.ListEducationalProducts()     [rate-limited]
            → deduplicate courses/orgs
        → app.Commands.InsertCourses.Handle()   [application layer]
          → domain.CourseRepository.CreateBatch() [domain port]
            → sqlite_course_repository            [adapter]

Key Design Decisions

  1. Pure-Go SQLite (modernc.org/sqlite) — no CGO dependency, builds anywhere.
  2. Embedded migrations — SQL files in migrations/sqlite/ are compiled into the binary via go:embed and applied at startup in a transaction.
  3. Templ for HTML — type-safe templates compiled to Go; generated _templ.go files are committed to the repo.
  4. CGO_ENABLED=0 everywhere — enforced by Taskfile and build flags.
  5. Go 1.26 — latest toolchain; see go.mod.
  6. OpenTelemetry — spans and metrics on every command/query handler and DB operation; exports to stdout or OTLP (HTTP/gRPC).
  7. Single composition rootservice.NewApplication is the only place that knows about concrete adapter types. Tests can swap adapters via interfaces.