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:
- Creates an OpenTelemetry span (
"command <Name>"/"query <Name>") - Logs structured fields (handler name, serialized args)
- Measures elapsed time
- Records errors on the span
- 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 byadapters/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.go—Application{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 aNew*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/— GenericCommandHandler[T]andQueryHandler[Q, U]interfaces + logging/tracing decorator implementations. Uses OpenTelemetry spans and structured slog logging.client/sravni/— HTTP client for sravni.ru withClientinterface,noop.Clientfallback, entities, and helpers. Usesgo-restyinternally.config/— Typed config structs:Log,HTTP,Sqlite,Trace,YDB,Duration.errors/—SimpleError,ValidationError, sentinel errors (ErrNotFound,ErrNotImplemented). Mapped to HTTP status codes inports/http.xslices/— Generic slice utilities:Map,Filter,ForEach,LRU.nullable/— GenericValue[T]for nullable fields (wraps a value + valid flag).xcontext/— Context helpers for propagating request-scoped log fields.xlog/— Adapters bridgingslogtocronlogger interface.generator/— ID generators (RandomInt64ID).
4. Adapters Layer (internal/kurious/adapters/)
Concrete implementations of domain interfaces:
sqlite_*_repository.go— SQLite-backed implementations usingsqlxnamed queries. Each defines a row type withAsDomain()for entity mapping.sqlite_connection.go— Connection factory; applies embedded migrations, returns ready-to-use repository instances.memory_mapper.go— In-memoryCourseMapperimplementation; loads dictionary counts from the course repository, maps IDs ↔ names.not_implemented.go— Sentinel adapter types returningErrNotImplementedfor 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 usinggorilla/mux.Serverstruct holdsservice.Applicationand delegates to handlers.course.gocontains the mainListandIndexhandlers 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/—BackgroundProcesswrappingrobfig/cron. Registers scheduled handlers likeSyncSravniHandler.background.go— Cron scheduler with job registration, shutdown, and next-run stats.services.go—Services{HTTP, Background}aggregate — the outermost composition point.
6. Service Layer (internal/kurious/service/)
The composition root:
service.go—NewApplication()selects DB engine, creates connections, builds adapter instances, constructs all command/query handlers (with logging decorators), and assembles theApplicationstruct. Also ownsClose()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
- Pure-Go SQLite (
modernc.org/sqlite) — no CGO dependency, builds anywhere. - Embedded migrations — SQL files in
migrations/sqlite/are compiled into the binary viago:embedand applied at startup in a transaction. - Templ for HTML — type-safe templates compiled to Go; generated
_templ.gofiles are committed to the repo. - CGO_ENABLED=0 everywhere — enforced by Taskfile and build flags.
- Go 1.26 — latest toolchain; see
go.mod. - OpenTelemetry — spans and metrics on every command/query handler and DB operation; exports to stdout or OTLP (HTTP/gRPC).
- Single composition root —
service.NewApplicationis the only place that knows about concrete adapter types. Tests can swap adapters via interfaces.