app_patterns

App Patterns

Key architectural patterns used throughout the codebase. Read this before adding new features.

Env Interface (Dependency Declaration)

Each use case declares a minimal Env interface with only the methods it needs. The app struct in cmd/server/main.go implements all of them.

// internal/case/requestemailsignin/resolve.go
type Env interface {
    UserByEmail(ctx context.Context, email string) (db.User, error)
    CreateSignInCode(ctx context.Context, userID int64) (string, error)
    TurnstileSiteKey() string
    VerifyCaptcha(ctx context.Context, token, remoteIP string) error
}

Compile-time checks in cmd/server/cronjobs.go verify app satisfies each interface:

var _ requestemailsignin.Env = app

Rule: Never import the full app — declare only what you use.

External Service Client (gitapi pattern)

External services follow: Config struct + New() constructor + Client with methods. The client is embedded in the app struct.

Reference: internal/gitapi/api.go, internal/turnstile/verify.go

// internal/turnstile/verify.go
type Config struct {
    SiteKey   string
    SecretKey string
}

func New(config Config) *Client {
    return &Client{config: config}
}

type Client struct {
    config    Config
    verifyURL string // override for testing
}

func (c *Client) VerifyCaptcha(ctx context.Context, token, remoteIP string) error { ... }

Config goes in internal/appconfig/config.go as a named field:

Turnstile turnstile.Config
GitAPI    gitapi.Config

Client is embedded in app:

type app struct {
    *turnstile.Client
    gitAPI *gitapi.API
}

Use cases call methods via Env: env.VerifyCaptcha(ctx, token, ip).

IO Belongs on the App Layer

Stateful resources (counters, caches, connections) live on the app struct, not as package globals. Use cases access them through Env methods.

// cmd/server/main.go
type app struct {
    signinCounter *requestemailsignin.SignInCounter
}

func (a *app) IncrementAndCheckSigninCounter() bool {
    threshold := a.captchaSigninThreshold()
    return a.signinCounter.IncrementAndCheck(threshold)
}

Rule: No package-level var counter = .... Put it on app, expose via Env.

Error Handling

Two error channels:

  • Business errors → return ErrorPayload, nil (user sees the message)
  • System errors → return nil, error (user sees "Internal Error")
// Business error (validation, rate limit, captcha)
if count > 3 {
    return &model.ErrorPayload{Message: "too_many_sign_in_codes"}, nil
}

// System error (infrastructure failure)
user, err := env.UserByEmail(ctx, input.Email)
if err != nil {
    return nil, fmt.Errorf("failed to get user by email: %w", err)
}

Use model.NewOzzoError() and model.NewFieldError() for validation errors.

Background Jobs (Cronjobs)

Jobs implement cronjobs.Job interface. Each has job.go + resolve.go.

Reference: internal/case/cronjob/cleanupwebhookdeliveries/

// job.go
type Job struct{}
func (j *Job) Name() string              { return "cleanup_webhook_deliveries" }
func (j *Job) Schedule() string           { return "0 0 3 * * *" } // 6-field cron (with seconds)
func (j *Job) ExecuteAfterStart() bool    { return false }
func (j *Job) Execute(ctx context.Context, env any) (any, error) {
    return Resolve(ctx, env.(Env))
}

// resolve.go
type Env interface {
    CleanupOldChangeWebhookDeliveries(ctx context.Context) error
    Logger() logger.Logger
}

func Resolve(ctx context.Context, env Env) (*Result, error) { ... }

Register in cmd/server/cronjobs.go:

func getCronJobConfigs(app *app) []cronjobs.Job {
    _ cleanupwebhookdeliveries.Env = app // compile-time check
    return []cronjobs.Job{
        &cleanupwebhookdeliveries.Job{},
    }
}

Config: Startup vs Runtime

Startup config (internal/appconfig/config.go): env vars + .env file, loaded once.

type Config struct {
    ListenAddr string `env:"LISTEN_ADDR"`
    Turnstile  turnstile.Config
    GitAPI     gitapi.Config
}

Runtime config (internal/configregistry/registry.go): DB-backed, changeable via admin panel.

const ConfigCaptchaSigninThreshold = "captcha_signin_threshold"

Registry[ConfigCaptchaSigninThreshold] = ConfigMeta{
    Description: "Max sign-in requests per hour before captcha",
    Type:        ConfigTypeInt,
    Default:     "5",
    Validate:    validateIntRange(1, 10000),
}

Rule: Use appconfig for secrets/infra. Use configregistry for admin-tunable behavior.

GraphQL Resolvers

Resolvers are thin — extract context, delegate to use case.

func (r *mutationResolver) RequestEmailSignInCode(
    ctx context.Context, input model.RequestEmailSignInCodeInput,
) (model.RequestEmailSignInCodeOrErrorPayload, error) {
    var remoteIP string
    if req, err := appreq.FromCtx(ctx); err == nil && req.Req != nil {
        remoteIP = req.Req.RemoteIP().String()
    }
    return requestemailsignin.Resolve(ctx, r.env(ctx), input, remoteIP)
}

Rule: No business logic in resolvers. Extract what the use case needs (IP, user token), pass r.env(ctx), return the result.

$mol Frontend Widgets

Widgets use view.tree (declarative structure) + view.ts (GraphQL + logic).

Reference: assets/ui/user/space/subscriptions/

view.tree — component structure with bindings:

$trip2g_user_space_subscriptions $mol_view
    sub /
        <= Page $mol_page
            title @ \Subscriptions
            body /
                <= List $mol_list
                    rows <= rows /
                        <= Row*0 $mol_row

view.ts — data fetching and transformation:

const request = $trip2g_graphql_request(/* GraphQL */ `
    query UserSubscriptions {
        viewer { user { subgraphAccesses { id createdAt subgraph { name } } } }
    }
`)

export class $trip2g_user_space_subscriptions extends $.$trip2g_user_space_subscriptions {
    @$mol_mem
    data(reset?: null) {
        const res = request()
        return $trip2g_graphql_make_map(res.viewer.user.subgraphAccesses)
    }

    override rows() {
        return this.data().map(key => this.Row(key))
    }

    override row_name(id: any): string {
        return this.row(id).subgraph.name
    }
}

Key conventions:

  • $trip2g_graphql_request() wraps GraphQL queries
  • $trip2g_graphql_make_map() creates indexed collections
  • @$mol_mem caches reactive data
  • @ \Text in view.tree marks translatable strings
  • Locale files: *.view.tree.locale=ru.json