agentleFS
Sign inSign up

golid / rules

golid-ai/golid/.cursor/rules/go-service.mdc

Patterns for Go service layer files — business logic, DB access, auth checks

Cursor rule40 starsChanged 4 months ago
---
description: Patterns for Go service layer files — business logic, DB access, auth checks
globs: backend/internal/service/*.go,backend/internal/service/**/*.go
alwaysApply: false
---

# Go Service Layer Patterns

> **Thesis:** Services own business logic and data access. They are framework-agnostic, verify resource membership, and never trust the caller to have checked permissions.
>
> **Refrain:** `Validate → Authorize → Execute → Respond`

**Reference files:** `auth/auth.go`, `user/user.go`

## Structure

```
type XService struct {
    pool              *pgxpool.Pool
    paginationDefault int
    paginationMax     int
}
func NewXService(pool *pgxpool.Pool, paginationDefault, paginationMax int) *XService { ... }
```

This is the canonical shape for paginated CRUD services. Real services
diverge based on their dependencies — `NewUserService(pool, publicBaseURL)`,
`NewAuthService(pool, jwtSecret, jwtIssuer, accessDuration, refreshDuration, passwordResetTTL)`,
`NewFeatureService(pool)` — but every
service follows the same "concrete struct + constructor + framework-agnostic
methods" rule. Match the constructor shape that fits the dependencies, not the
template literally.

**Before modifying a module's service** (changes to status transitions, cross-module
calls, or the API surface), read `docs/modules/{module}/spec.md` for state machine
constraints and business rules. If available, also check `docs/dependency-graph.md`
for blast radius and `docs/flows.md` for cross-module transaction boundaries.

- Constructor takes `*pgxpool.Pool` plus whatever the service genuinely needs (pagination config for list endpoints, external service handles, frontend URL for redirects, etc.).
- **No Echo imports** — services must be framework-agnostic. No `echo.Context`, no `echo.NewHTTPError`. Use `apperror` for errors, plain `context.Context` for context.
- Define input/output structs in the same file (e.g., `CreateItemInput`, `ItemDetail`).
- Return `*Detail` structs with formatted timestamps (`time.RFC3339`), not raw DB types.

## Auth Pattern

Every method that operates on a scoped resource must verify membership, not just role:

```go
// Resolve user → entity relationship
access, err := s.verifyResourceAccess(ctx, resourceID, userID)
// Then check specific permissions
if !access.IsAdmin { return apperror.Forbidden("...") }
```

Create a `verifyXAccess` helper that chains: fetch resource → get parent → verify membership.

## Validation

- **Bcrypt 72-byte limit** — validate with `len([]byte(password)) > 72`, not `len(password) > 72`. Multi-byte UTF-8 characters undercount with `len(string)`. Error messages should say "72 bytes" not "72 characters."

## SQL Rules

- **Parameterized queries only** — `$1, $2, ...` placeholders. Never `fmt.Sprintf` values or identifiers into SQL; only placeholder index strings like `$%d` are acceptable.
- **Dynamic WHERE clauses** — use `argIdx` counter pattern for building filtered list queries.
- **Computed fields via subquery** — e.g., `(SELECT COALESCE(SUM(hours), 0) FROM time_entries WHERE task_id = t.id) AS actual_hours`. Never store computed values.
- **`rows.Err()` after every `rows.Next()` loop** — partial iteration errors (network, context cancellation) are only surfaced by `rows.Err()`. Without it, the loop silently returns incomplete data.

## Transactions

Wrap any operation with 2+ writes:

```go
tx, err := s.pool.Begin(ctx)
if err != nil { return apperror.Internal(fmt.Errorf("begin tx: %w", err)) }
defer func() { _ = tx.Rollback(ctx) }()  // no-op after commit — safe to discard
// ... all queries on tx ...
if err := tx.Commit(ctx); err != nil { ... }
```

For operations that need both a check and a mutation (read-then-write), use atomic `UPDATE ... WHERE guard RETURNING` inside the transaction to prevent TOCTOU races. Never SELECT outside the transaction then UPDATE inside it. See `auth/auth.go` `Refresh` method.

## Status Guards

Check parent entity status at the top of mutating methods:

```go
if access.Status != "active" {
    return apperror.BadRequest("Can only update items on active resources")
}
```

## Pagination

Use `pagination.NormalizePagination(page, perPage, s.paginationDefault, s.paginationMax)` from `internal/pagination`. Return `XListResult` with `total`, `page`, `per_page`, `total_pages`.

## Related

- **Error translation, nullable scans, history, side effects:** `go-service-errors`

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.