14 KiB
AGENTS.md
Guidance for AI agents (and humans) working in this repository.
Project Overview
kurious (git.loyso.art/frx/kurious) is a course/education platform that
aggregates and serves educational course listings. It scrapes/syncs course data
from an external source (sravni.ru) via a rate-limited HTTP client, stores it
locally, and exposes it through a server-rendered web UI with filtering,
pagination, and statistics.
The project is written in Go and follows a hexagonal (ports & adapters) architecture with a CQRS-flavored application layer.
Tech Stack
- Language: Go 1.26 (toolchain
go1.26.4; seego.mod) - HTTP routing:
github.com/gorilla/mux - HTTP client:
github.com/go-resty/resty/v2(sravni.ru scraper) - Database: SQLite via
modernc.org/sqlite(pure-Go, CGO disabled). YDB was historically supported but is no longer supported (service.NewApplicationreturns an error for the YDB engine). - DB access:
github.com/jmoiron/sqlx(named queries) - Templating:
github.com/a-h/templ(.templfiles compile to Go) - Observability: OpenTelemetry (
go.opentelemetry.io/otel) — traces and metrics with stdout / OTLP (HTTP & gRPC) exporters - Background jobs:
github.com/robfig/cron/v3 - Logging: standard
log/slog(text or JSON, configurable) - Rate limiting:
golang.org/x/time/rate - Testing:
github.com/stretchr/testify(assert, require, mock) - Mocking:
github.com/vektra/mockery/v2(config in.mockery.yaml) - Linting:
golangci-lintv1.55.2 - Build/task runner: Taskfile (
Taskfile.yml) - Build flags: ldflags inject
version,commit,buildTime(seekurious.go)
Architecture
The codebase implements a hexagonal architecture (ports & adapters) with a CQRS-style separation between commands (writes) and queries (reads).
┌─────────────────────────────────────────────┐
delivery │ ports/ (HTTP server, cron jobs) │
mechanisms └──────────────────────┬──────────────────────┘
│ depends on
┌──────────────────────▼──────────────────────┐
application │ app/ (command/, query/) │
layer │ app.Application { Commands, Queries } │
│ decorator/ (logging decorators) │
└──────────────────────┬──────────────────────┘
│ depends on
┌──────────────────────▼──────────────────────┐
domain │ domain/ (entities, repository ports) │
└──────────────────────┬──────────────────────┘
│ implemented by
┌──────────────────────▼──────────────────────┐
adapters │ adapters/ (sqlite_*, memory_mapper, │
│ ydb_* legacy stub) │
└─────────────────────────────────────────────┘
domain/— pure business entities (Course,Organization,LearningCategory) and the repository interfaces (ports) that the application depends on:CourseRepository,OrganizationRepository,LearningCategoryRepository. No I/O or framework code lives here.app/— the application layer.app.Applicationaggregates aCommandsstruct and aQueriesstruct. Handlers inapp/command/perform writes; handlers inapp/query/perform reads. Each handler implements a genericdecorator.CommandHandler[T]ordecorator.QueryHandler[Q, U]interface and is wrapped with logging decorators at construction time.adapters/— concrete implementations of the domain repository ports.sqlite_course_repository.go,sqlite_organization_repository.go, etc. translate between domain types and SQL rows.memory_mapper.gois an in-memory mapper translating external dictionary IDs to human-readable names.ports/— delivery mechanisms.ports/http/is the HTTP server (gorilla/mux- templ templates);
ports/background/runs scheduled cron jobs (e.g.SyncSravniHandler).
- templ templates);
service/— the composition root.service.NewApplicationselects the DB engine, constructs the SQLite connection, builds the adapters, and wires everything into anapp.Application. This is what binaries call.
Data flows: an HTTP request enters ports/http, calls a handler on
service.Application (which delegates to app/command or app/query), which
calls a domain repository interface implemented by an adapters/* repository.
Directory Structure
.
├── kurious.go # Root package: version/commit/buildTime getters
├── Taskfile.yml # Build, test, lint, generate, run task definitions
├── .mockery.yaml # mockery config (with-expecter, keeptree)
├── go.mod / go.sum
├── cmd/ # Entry points (one package per binary)
│ ├── kuriweb/ # Main HTTP web server (config.go, main.go, http.go, trace.go)
│ ├── background/ # Background sync process (cron-driven sravni sync)
│ └── dev/sravnicli/ # Developer CLI for inspecting the sravni source
├── internal/
│ ├── kurious/ # Application core (the hexagon)
│ │ ├── domain/ # Entities + repository interface ports
│ │ ├── app/
│ │ │ ├── app.go # Application{Commands, Queries} aggregate
│ │ │ ├── command/ # Write-side handlers (Create, Delete, Update...)
│ │ │ └── query/ # Read-side handlers (List, Get, Stats...)
│ │ ├── adapters/ # Repository implementations + mocks/
│ │ ├── ports/
│ │ │ ├── http/ # HTTP server, course routes
│ │ │ │ └── bootstrap/ # templ templates (.templ) + generated _templ.go
│ │ │ ├── background/ # Cron job handlers
│ │ │ ├── background.go # BackgroundProcess (cron scheduler)
│ │ │ └── services.go # Services{HTTP, Background} aggregate
│ │ └── service/ # Composition root: NewApplication wiring
│ └── common/ # Shared, reusable utilities
│ ├── client/sravni/ # sravni.ru HTTP client + mocks/
│ ├── config/ # Config structs (HTTP, Log, Sqlite, Trace, YDB, Duration)
│ ├── decorator/ # Command/Query handler interfaces + logging decorators
│ ├── errors/ # SimpleError, ValidationError, sentinel errors
│ ├── generator/ # ID generators
│ ├── nullable/ # Generic nullable Value[T]
│ ├── xcontext/ # context helpers (request id, log fields)
│ ├── xlog/ # slog + cron logger adapters
│ └── xslices/ # slice helpers (Map, Filter, ForEach, LRU)
├── pkg/
│ └── xdefault/ # WithFallback helper (public-ish pkg)
├── migrations/
│ └── sqlite/ # SQL migrations (embed.FS) + migrator.go
├── assets/kurious/static/ # Static web assets (embedded via go:embed)
└── htmlexamples/ # Standalone HTML/templ prototyping examples
Build, Test, and Lint Commands
All operations are driven by Taskfile (task <name>). The toolchain is
installed into a local bin/ (GOBIN={{.USER_WORKING_DIR}}/bin) and
CGO_ENABLED=0 is enforced.
| Command | What it does |
|---|---|
task install_tools |
Install golangci-lint, templ, mockery into bin/ |
task generate |
Run templ generate (compiles .templ → _templ.go) |
task mocks |
Run go generate ./internal/... (regenerate mockery mocks) |
task check |
Run golangci-lint run ./... (depends on generate) |
task test |
Run go test ./internal/... (depends on generate) |
task build_web |
Build bin/kuriousweb (depends on check + test) |
task build_background |
Build bin/kuriousbg (depends on check + test) |
task build_dev_cli |
Build bin/sravnicli (depends on check + test) |
task build |
Build all three binaries |
task run |
Build then run bin/kuriousweb |
Typical workflow before committing: task check && task test.
Running the binaries
Binaries read a JSON config file path as their first argument, defaulting to
config.json (note: *.json is gitignored, so configs are local). Example
fields: log, sqlite, http.listen_addr, db_engine, tracing.
./bin/kuriousweb path/to/config.json
Code Conventions
Decorators / handler pattern
Command and query handlers implement generic interfaces and are wrapped with logging decorators at construction time. When adding a new use case:
- Define the command/query struct and a
*Handlertype alias ofdecorator.CommandHandler[T]/decorator.QueryHandler[Q, U]inapp/command/orapp/query/. - Implement a private
*Handlerstruct holding its repository dependencies. - Expose a
New*Handler(deps..., log *slog.Logger)constructor that returns the decorated handler (decorator.ApplyCommandDecorators/decorator.AddQueryDecorators). - Register the new handler on
app.Commands/app.Queriesinapp/app.goand wire it inservice/service.go.
Mock generation (mockery)
- Interfaces marked with
//go:generate mockery ...directives (seedomain/repository.go,common/client/sravni/client.go) are mocked into siblingmocks/packages (keeptree: True,with-expecter: true). - Regenerate with
task mocks(runsgo generate ./internal/...). - Mocks are committed to the repo.
Templating (templ)
- HTML is authored as
.templfiles underinternal/kurious/ports/http/bootstrap/. task generatecompiles them to*_templ.go(committed). Run it whenever a.templfile changes;task checkandtask testboth depend on it.
Database / migrations
- SQLite migrations live in
migrations/sqlite/*.sqland are embedded viago:embed. The migrator (migrations/sqlite/migrator.go) applies them in order inside a transaction. - Repository adapters use
sqlxnamed queries; domain ↔ row translation is centralized viaAsDomain()methods.
Logging & observability
- Use
log/slogwith structured attributes. Request-scoped fields (e.g.request_id) are propagated throughcontextviainternal/common/xcontext. - HTTP handlers wrap requests in trace spans and record metrics
(request duration histogram). Database adapters emit spans tagged with
db.*attributes.
Error handling
- Domain/repository errors use sentinels from
internal/common/errors(ErrNotFound,ErrNotImplemented) and*ValidationError(mapped to HTTP 400/404 inports/http/server.go). - Wrap errors with
fmt.Errorf("doing X: %w", err)to add context while preserving the underlying error forerrors.Is/errors.As.
Testing Patterns
- Scope: unit tests live next to the code they test
(
*_test.go).task testrunsgo test ./internal/.... - Framework:
github.com/stretchr/testify—requirefor fatal assertions,assertfor non-fatal,mockfor mock interactions. - Mocks: generated mockery mocks with the expecter API:
Note: when the generated mock does not yet implement every interface method, tests embed the mock and add the missing methods (see the
repo.EXPECT().Create(mock.Anything, expectedParams). Return(domain.Course{ID: "c1"}, nil).Once()fullRepopattern ininternal/kurious/app/command/command_test.go). - Logger: tests use a
quietLogger()helper that discards output (slog.New(slog.NewTextHandler(io.Discard, nil))). - Adapter tests:
internal/kurious/adapters/sqlite_*_test.goexercise the real SQLite repositories against an in-memory/temp database. - Conventions: table-driven where appropriate; use
context.Background()in tests; assert on wrapped errors withassert.ErrorIsand message fragments withassert.Contains.
Notes for Agents
- Run
task generatebefore linting/testing if you touched any.templfile. - Do not re-introduce YDB as a working engine without updating
service.NewApplicationand the migration story (currently SQLite-only). - Generated files (
*_templ.go,mocks/*.go) are committed — regenerate and commit them alongside source changes. *.json,*.sqlite,bin/, and*.logare gitignored; do not commit local configs or databases.