aboutsummaryrefslogtreecommitdiff

Easel Development Guide

Internal development guide for the Easel e-commerce platform. See README.md for setup and the user-facing summary.

Project Goals

Easel is simple, fast, and secure:

  • Simple: easy setup and maintenance for Linux users, vertical scaling with SQLite
  • Fast: Go with server-side templates, minimal JavaScript
  • Secure: battle-tested patterns for financial data handling

The target user is a solo, non-technical merchant. Before adding a feature, ask whether it is needed on the path from signup to first sale. Guest checkout is the default and must never require an account; storefront customer accounts are optional, for shoppers who want them. Out of scope: multi-location inventory (one stock quantity per variant), custom CSS/themes (fixed presets), purchase orders, and suppliers.

Quick Start

  1. Install Nix with flakes enabled
  2. nix develop
  3. make deps
  4. make init
  5. make up

Architecture

Clean Architecture with dependencies flowing only inward:

Infrastructure → Presentation → Application → Domain
  • Domain (internal/domain/): core business logic, no dependencies
  • Application (internal/application/): services, imports domain
  • Presentation (internal/presentation/): views, templates, i18n
  • Infrastructure (internal/infrastructure/): HTTP, DB, SMTP, Stripe

Never import from an outer layer.

HTTP Handlers

A handler must call at most one service method (0 or 1) and render the result. When a page needs several pieces of data, extend the existing service method (or add one) so it assembles everything the handler renders. The same applies to read-then-write flows: move the read into the write method instead of calling a getter then a setter. Middleware is exempt because it coordinates the request pipeline rather than an application action. make lint runs cmd/typelint, whose handlerlint check counts the calls along each way through a handler.

Admin pages declare where they sit in the dashboard menu with a nav place, such as newSidebar(r, t, nav.Order(order, hasDownloads).Refunds()). The menu, its active item, and the page title come from that value, never from the URL. A new page adds its place to internal/presentation/nav and a row to the table in nav_test.go.

Roles

A store's users are owners or managers (domain.Role). Each dashboard route names the domain.Capability it takes, such as orders(getOrders(...)) in AddRoutes, and a role missing it gets the forbidden page. A manager may use only the capabilities managerCapabilities lists, so a new capability is the owners' until it's granted there. The menu hides what the role can't open, but that's only tidiness: a service method doing something only an owner may starts with domain.Authorize(ctx, capability). An API key acts as an owner, limited by its scopes.

Easel's staff have no standing access to a store. Its owner grants it for a while with a code (internal/application/staffaccess), and the staff member is then in the dashboard as domain.RoleStaff, limited to staffCapabilities, with domain.StaffActor naming them and the access on every change. A new owner capability is therefore also refused to staff until it's granted there. In the console, domain.StaffActor names the staff member signed in, without an access. Every member may look around, reset a user's passkeys, moderate, and redeem a store's code, but billing, subscription tiers, changing a store's tier, deleting stores and users, and changing the staff are the operator's alone (domain.PlatformStaff.IsOperator): the service method takes who's asking and refuses anyone else with domain.ErrForbidden, the console's routes answer them 403 (operatorOnly), and its pages hide the buttons.

API List Endpoints

Every REST list endpoint whose item count can grow without bound must paginate; no unbounded data array. Follow the cursor pattern used by the product list: accept limit (default 10, max 100) and after (public ID of the last item), and return has_more. Repositories expose ListAfter(ctx, params) fetching limit + 1 rows. If ranked by a non-ID column, page by the full sort key (e.g. (rank, id)).

Code Style

Naming

Use precise, domain-specific type names (ProductID, ProductStatus). Services end in Service, take dependencies via constructor, and are named NewService. Repository methods follow a fixed pattern:

  • Get(ctx, id) / GetForPublicID(ctx, publicID) - single entity
  • List(ctx, parentID) / ListAfter(ctx, params) - children / pagination
  • Create(ctx, params) / Update(ctx, params) / Delete(ctx, id)

Use structured params for methods with more than two parameters (CreateProductParams).

Errors

Every sentinel error must be a _err constant, never var ErrFoo = errors.New(...):

const (
    ErrDuplicate    = _err("duplicate")
    ErrMissing      = _err("missing")
    ErrUnauthorized = _err("unauthorized")
)

type _err string
func (e _err) Error() string { return string(e) }
func (e _err) Is(target error) bool { ... }

Sentinel errors that wrap another error (e.g. to satisfy errors.Is against a domain error) are the one exception and stay var using fmt.Errorf with %w.

Application errors are wrapped with context, and functions always return the zero value on error:

func (s Service) GetProduct(ctx context.Context, id ProductID) (Product, error) {
    var zero Product
    product, err := s.productRepo.Get(ctx, id)
    if err != nil {
        return zero, fmt.Errorf("product repo get: %w", err)
    }
    return product, nil
}

Database

Repositories implement interfaces defined in the application layer (the consumer), not the infrastructure layer. SQL lives in internal/infrastructure/sqlite/queries/*.sql; generate typed code into internal/infrastructure/sqlite/repo/ with sqlc generate. Writes go through DB.Exec, reads through DB.Query:

err := r.db.Exec(func(q repo.Querier) error {
    return q.CreateProduct(ctx, repo.CreateProductParams{...})
})

Activity Log

Every change a person or API key makes is logged; only automatic changes (checkout stock decrements, refund restocks, background jobs) are not. A new feature's write builds its event and passes it to application.Audited, as below, and adds its record's kind to domain.AuditEntityTypes when it's a new one.

A change a merchant or an API key makes goes in the store's activity log (audit_events), which record pages show as their History and Settings › Activity shows in full. Build the events with domain.NewAuditEvent or the helpers in internal/application/audit.go, and pass them to application.Audited with the write, so they commit in its transaction. The actor comes from the request's context. The repo method must use DB.WriteTx, not Exec; otherwise the events are dropped and logged as an error. Never put a secret in an event, only that it was replaced or removed, and no customer's personal data: the log outlives a customer's erasure, so a customer is logged by public ID and their fields as changed. A service the storefront shares logs only a person's changes (application.ByPerson). Orders keep their own history in order_events, and gift cards theirs in their transactions, so neither is logged twice. A change made in several writes is saved in one write transaction, so its event can't outlive a write that failed.

The REST API serves the log at GET /api/v1/audit-events/, so whatever an event holds reaches API clients too.

A change Easel's staff make, from the console or in a store's dashboard under its owner's grant, is logged with the platform actor naming the staff member (domain.StaffActor), which the console's sign-in and the dashboard's staff session put on the context, so a console action that logs a change names who made it rather than Easel itself.

What the staff do from the console that no store's log can keep, such as deleting a store, whose log goes with it, or a dashboard user, resetting a user's passkeys, which a user in no store has no other record of, or a change to the staff, goes in the console's own log (platform_events), shown on its Log page. Build the event with domain.NewPlatformEvent and pass it to application.PlatformLogged with the write, which, as with Audited, must use DB.WriteTx. It names a user by public ID alone.

Moderation

Easel hosts what stores and their shoppers publish, so its staff review it in the console (internal/application/moderation). A change a person makes is queued for review from its audit events, in the same write transaction (domain.ModerationTargetFor); an upload or a shopper's review queues itself (db.QueueModeration). A new kind of user-generated content must join the queue: add its domain.ModerationKind, queue it where it's written, show it in ModerationRepo.GetContent, and, if it can be taken down, hide it from the storefront while taken_down_at is set and refuse the merchant showing it again (domain.ErrTakenDown).

Templates, Views, and Public IDs

  • .templ files generate Go with templ generate.
  • View models transform domain entities for presentation (ProductFrom(...)).
  • Expose xid-based public IDs, not database IDs, on external interfaces.
  • A link to one value on a multi-field form goes to #<field> on that form, such as edit/#weight, with the id in kebab-case on the field's <label> (or on its <fieldset> for a group of radios), so the label stays in view. forms.css marks the field it lands on. Keep such fields out of a closed <details>, which a link can't open without JavaScript.
  • A dashboard page's subtitle or one-paragraph intro, saying what the page is for or whose it is, goes in an <hgroup> with its <h1>, which keeps it close under the title.

Time, Context, and Nulls

  • Always use UTC: time.Now().UTC().
  • Store information flows through context (domain.StoreMust, domain.StoreTo).
  • Optional fields use domain.Null[T].

Testing

  • Cover business logic and integration points; mock external dependencies.
  • Standard library only (no Testify); prefer table-driven tests.
  • Unit tests cover domain logic and services; repository tests use a real SQLite DB; handler tests use the mock service pattern with a test context holding store, logger, and request ID.
  • Use subtests (a map keyed by name) for similar expected results, and a separate test for success cases.
  • Put a test in the _test.go file matching the source that defines what it tests: foo_test.go exists only beside foo.go (or foo.templ, foo.sql). Test support (main_test.go, mocks, helpers) is exempt, as are integration tests in cmd/... packages.
  • Every return of a sentinel or typed error (ErrX, ErrInvalidField{}, or fmt.Errorf("...: %w", domain.ErrX)) needs a test that reaches it and asserts it with errors.Is or errors.As, never by string, so a change that stops returning it fails. Returns that only wrap a callee's err are exempt.
  • Fuzz targets' seeds run in make test; fuzz one with go test ./pkg/ -run '^$' -fuzz FuzzName -fuzztime 30s, and keep crashers it writes under testdata/fuzz.

Route Coverage

Every admin route is exercised in internal/infrastructure/xhttp/httpadmin/route_test.go. When adding a route to AddRoutes:

  • Add the exact ServeMux pattern to allRoutes(), setting platformOnly: true for platform-only routes.
  • TestAddRoutesAuthenticated drives every route: GET must render 200, and POST must submit a form and reach its success path. Add required fields to formFor(), list multipart routes in multipartForms(), and use authenticatedStatus() for legitimate non-200 responses.
  • Add fixtures to routeTestService(), and add zero-value methods to missing_methods_test.go when mockAdminService lacks one.

Run with go test ./internal/infrastructure/xhttp/httpadmin/ -run TestAddRoutes.

That test uses a mock service, so a POST reaching its success path there says nothing about the database. Code that builds records from existing ones (duplicating, converting, reordering) must also run against the development seed, whose products have multi-value options, swatches, sales, images, and tags: use createSeededTestDB in repository tests and startSeededTestServer in cmd/easel. TestAdminSeededOneClickActions crawls the seeded dashboard and presses every button that posts without input, apart from deletes and archives, so a new one-click action is covered once a page links to it.

Development Workflow

Before committing:

make test    # linter and tests
make templ   # if templates changed
make sqlc    # if SQL changed
make seed    # if the schema changed

Translations

There are two kinds of translation, with a tool each:

  • cmd/i18n edits Easel's interface strings, which ship with the code and are covered below.
  • cmd/translate translates a store's own content (products, pages, collections, and so on) through the REST API, the way a merchant would. Those translations live in the database, not the repo; see go run ./cmd/translate -h.

Interface translations live in internal/presentation/i18n/<lang>.json. Never edit them by hand; use the i18n tool:

go run ./cmd/i18n -h
go run ./cmd/i18n get my.key
go run ./cmd/i18n set my.key en:"Hello" pt:"Olá"
go run ./cmd/i18n cp old.key new.key
go run ./cmd/i18n del my.key

New keys must be supplied in every language. Scope keys to the feature that renders them; do not reuse a key across features even when the text matches, and extract a shared component instead of duplicating keys. Use cp to split a key (copying preserves all translations, then del the old one once unused). The en bundle is the source of truth. Run make lint afterward (i18nlint catches missing, unused, or out-of-order keys).

A translation that takes values holds fmt verbs, and T formats it with the args that follow the key, as in T("product_variants.max_reached", domain.MaxVariants); never wrap T in fmt.Sprintf. Without args T returns the text as is, so a % in it is safe; with args, write %% for a percent sign. Every language uses the same verbs as English, reordering them with explicit indexes such as %[2]s when its word order differs. i18nlint checks that, and that each call passes as many args as the English text formats.

Counts ("3 refunds") use CLDR plural rules, never a n == 1 branch. Store one key per category, holding %d, under a base key: English needs order.refunds.one and .other, while Polish needs .one, .few, and .many. Each language holds only the categories its rules use for whole numbers, which i18n set and i18nlint both enforce. Render the count with view.Plural(T, "order.refunds", n).

Stripe Integration Tests

Most tests use a fake Stripe backend. The real-Stripe tests (checkout, discount, account-reactivation, platform refunds) load Stripe.js and call Stripe over the internet, so make test skips them. Run them with make test-stripe, which sets EASEL_TEST_STRIPE=1. They read EASEL_STRIPE_SECRET and EASEL_STRIPE_PUBLISHABLE_KEY, and the secret must be a test key with Customers and Payment Methods access.

make test-stripe

Build Commands

  • make init - prepare a fresh checkout and seed the DB
  • make up - run the development server
  • make migrate - run migrations
  • make test - lint and test, then deadlint, which fails on any function no main package reaches, and on any exported method whose name no non-test code selects: an interface's (sqlc's Querier included), or a concrete type's that satisfies no interface of another module. Delete an uncalled query from queries/*.sql and run make sqlc. A function only tests call, such as a fake shared between packages, needs a doc comment line starting deadcode:testonly with the reason; put it on the package's doc comment when the whole package is for tests. It also runs sentinellint, which fails when no test runs a return of a sentinel or typed error (ErrX, ErrField{}, fmt.Errorf("...: %w", domain.ErrX)); the test should assert it with errors.Is or errors.As. Mark an unreachable one with a comment on or above its line starting sentinellint:untested with the reason
  • make lint - golangci-lint, i18nlint, timelint, typelint (fieldlint, limitlint, handlerlint), slicelint, leaklint
  • make assets - build CSS/JS. A new stylesheet in assets/css/admin/ or assets/css/store/ must also be listed in assets/css/admin.txt or store.txt, which sets the order the files are concatenated in. Production builds run MINIFY=1 make assets, which minifies the CSS with go tool esbuild; the hashed names stay the same as an unminified build
  • make seed - reset the DB with seed data

Conventions

  • Follow existing code and naming; check imports/go.mod before assuming a library is available.
  • Avoid pointers unless mutability is required.
  • Add Godoc comments and keep files small and specific (no grab-bag files); comment on "why", not "what".
  • Define interfaces in the consumer, not the producer.
  • Limit lines to 80 characters; don't export what can stay internal.
  • Never commit secrets; use public IDs to prevent enumeration; validate input in the application layer; use CSRF protection on forms; run all queries through SQLC.

Commit Messages

Start with a lowercase verb, keep the subject under 50 characters (72 max), and wrap any body at 72. Focus on "what", not "how", and only add a body when the reasoning isn't obvious.

add product recommendations
fix missing price and images for recommendations
improve store phase 2 - inventory

This repo is checked out with both jj (Jujutsu) and git. The expected workflow is to make each logical change its own jj change: jj new, edit, jj describe, and continue. The commit style above is the same either way, so a git-only workflow (git checkout -b, commit, push) is fine too.