SQL Queries
SQL is explicit and colocated. Every domain owns an <entity>.sql file next to its Go file, and narsilc compiles those files into typed Go in domains/internal/queries. There is no central query directory and no separate sqlc configuration file.
Write the query beside the domain
Queries are annotated SQL. The annotation fixes the generated method shape and the @order comment names a column the generated builder can sort by.
1-- name: GetUser :one
2SELECT *
3FROM users
4WHERE id = $1
5LIMIT 1;
6
7-- name: ListUsers :many
8-- @order id
9SELECT *
10FROM users;
11
12-- name: CreateUser :one
13INSERT INTO users (
14 id, created_at, updated_at, email, email_validated_at, password, is_admin, session_version
15) VALUES (
16 $1, $2, $3, $4, $5, $6, $7, $8
17)
18RETURNING *;
Narsilc understands :one, :many, :exec, and :execrows. Keep the SQL with the receiver that calls it: domains/user.sql for domains.Users, domains/token.sql for domains.Tokens.
Compile with andurel sync queries
1andurel sync queries --json
Narsilc globs domains/**/*.sql and writes domains/internal/queries. The command is a no-op when there are no annotated query files. andurel run and andurel build also regenerate when query files exist, and andurel doctor reports drift.
Only domains/** may import the generated package. andurel verify reports queries-import if a handler, service, or test imports it directly.
Call generated queries from a receiver
Generated clients accept storage.Connection directly, and every query is generic over the entity, so the domain maps rows itself:
1func NewUsers(db storage.Connection) Users {
2 return Users{transactor: storage.NewTransactor(db), queries: queries.New(db)}
3}
4
5func (u Users) Find(ctx context.Context, id uuid.UUID) (User, error) {
6 return storage.Row(u.queries.GetUser[User](ctx, id))
7}
8
9func (u Users) Create(ctx context.Context, pepper string, data CreateUserData) (User, error) {
10 // ...build, normalise and validate the entity; hash the password...
11 created, err := u.queries.CreateUser[User](ctx, queries.CreateUserParams{
12 ID: entity.ID,
13 CreatedAt: entity.CreatedAt,
14 UpdatedAt: entity.UpdatedAt,
15 Email: entity.Email,
16 EmailValidatedAt: entity.EmailValidatedAt,
17 Password: entity.Password,
18 IsAdmin: entity.IsAdmin,
19 SessionVersion: entity.SessionVersion,
20 })
21
22 return storage.Row(created, storage.Conflict(err, "users_email_key"))
23}
Map expected failures with storage.Row and storage.Conflict, and return the entity, never queries.UserRow.
Paginate with the generated builder
1req := storage.Page(page, pageSize)
2
3totalCount, err := u.queries.CountUsers(ctx)
4if err != nil {
5 return storage.Paginated[User]{}, err
6}
7
8entities, err := u.queries.ListUsers[User](ctx).
9 Limit(req.Limit).
10 Offset(req.Offset).
11 All()
12if err != nil {
13 return storage.Paginated[User]{}, err
14}
15
16return storage.NewPaginated(entities, int64(totalCount), req), nil
storage.Page clamps the page, the page size, and the int32 offset, so an oversized or negative page cannot overflow the query.
Share a transaction
Bind the receiver, not the generated client:
1err := storage.AtomicOps(ctx, i.transactor, func(ctx context.Context, tx storage.Tx) error {
2 users, tokens := i.users.InTx(tx), i.tokens.InTx(tx)
3
4 token, err := tokens.FindByUserAndScope(ctx, userID, domains.UserEmailVerification)
5 if err != nil {
6 return err
7 }
8
9 if _, err := tokens.IncrementAttempts(ctx, token.ID); err != nil {
10 return err
11 }
12
13 validatedAt := pgtype.Timestamptz{Time: time.Now(), Valid: true}
14 _, err = users.Update(ctx, domains.UpdateUserData{
15 ID: userID,
16 EmailValidatedAt: &validatedAt,
17 })
18
19 return err
20})
InTx rebinds both the generated queries and the receiver's transactor to the open transaction. A service never unwraps a transaction or calls generated queries itself.
Keep the mapping honest
The Go entity is the source of truth for columns. Every column an automatic generated query returns must map to an exported andurel tag; otherwise andurel verify reports entity-columns. If a query narrows the columns, expose only fields that exist on the entity.
1andurel inspect domains --json
2andurel patterns show domain --entity Product --table products
inspect domains lists columns_ok, mismatches, and unpopulated_tags per entity. patterns show domain --entity prints the file manifest for a new entity, including the entity, receiver, Validate stub, and CRUD SQL when the generated row type already exists.