Packages@downcity/city

Hooks and Service Mounting

The three hook layers, service mounting, and why they are the shared extension points of City.

A lot of shared capability in Downcity is not created by copying logic into every action. It enters City through hooks and services.

What each concept solves

The easiest split is:

  • hooks: insert rules into an existing call lifecycle
  • services: add a full reusable capability set into City

That is why auth, usage, and payment do not become separate systems. They become extension points inside the same Federation.

When to think hook first

  • you want logic before or after existing calls
  • the logic is cross-cutting and should not live in every action
  • the capability is local to your project and does not need to be its own package

Typical examples:

  • quotas
  • logging
  • risk checks
  • charging after a successful call

When to think service first

  • the capability should be reused across many products
  • it is more than before or after logic and needs its own service or tables
  • you want product-side callers to reach it through a stable service boundary

Typical examples:

  • @downcity/services
  • @downcity/services
  • @downcity/services

First understand the three hook positions

global
  -> service
    -> action

Their meanings are:

  • global: shared by all services and actions
  • service: shared by all actions in one service
  • action: only affects one concrete action

Common service mounting example

import { Federation } from "@downcity/city";
import {
  AccountsService,
  BalanceService,
  PaymentService,
  googleAccountsProvider,
  stripePaymentProvider,
  UsageService,
} from "@downcity/services";

const base = new Federation({ db });
const balance = new BalanceService();

base.use(new AccountsService({
  token_ttl: "7d",
  providers: [
    googleAccountsProvider(),
  ],
}));

base.use(balance);
base.use(new PaymentService({
  readTopup: async (topup_id) => await balance.readTopup(topup_id),
  finishTopup: async (topup_id, extra) => await balance.finishTopup(topup_id, extra),
  providers: [
    stripePaymentProvider({
      secret_key: process.env.STRIPE_SECRET_KEY,
      webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
    }),
  ],
}));
base.use(new UsageService({
  record_errors: true,
}));

In this setup, AccountsService owns the accounts HTTP surface. Mounting it once exposes /providers, me, admin reads, the better-auth passthrough route, and the OAuth callback route. Concrete login methods appear only when the matching provider is mounted and configured.

How product-side callers reach services

Once mounted, services still look like normal services from the product side:

const session = await guest.service("accounts").action("login").invoke({
  email: "[email protected]",
  password: "password123",
  city_id: "city_demo",
});

const usage = await admin.service("usage").get("summary");

const methods = await guest.service("payment").get("methods");

const checkout = await user.service("payment").action("checkout/create").invoke({
  method_id: "stripe",
  topup_id: "topup_demo",
});

That means payment is both the discovery entry and the execution entry; Stripe is selected as a provider.

Relationship between hooks, services, and services

A useful chain is:

service / action
  -> hooks add lifecycle rules
  -> services add reusable services, tables, and management capability
  -> clients still call everything through /v1/*

Common API surface

  • base.use(service)
  • guest.service("accounts")...
  • guest.service("payment").get("methods")
  • user.service("usage")...
  • admin.service("payment").get("payments")

Common misunderstandings

A service is not just another npm package

It often represents a whole boundary of services, tables, admin surfaces, and optional hook behavior.

Hooks are not a place to hide full business features

Hooks are best for cross-cutting governance logic, not for turning every feature into middleware.

Services do not replace City

Services add capability into the Federation. The product-side calling surface is still @downcity/city.