PocketQuest
Plan-based budgeting with structured periods
Demo
videoPocketQuest - Mobile Demo (React Native + Expo)
System Architecture
system-firstThis project is designed around clear boundaries: shared contracts at the edge, domain rules in the service layer, and persistence isolated behind repositories.
Domain Rules
principlesPocketQuest is designed to be predictable and maintainable: policies live in the domain layer, boundaries are explicit, and infrastructure details don't leak into the UI.
Impact: This structure eliminates API drift between client and server, reduces duplicated validation logic, and prevents timezone-related reporting inconsistencies. Refactors remain localized to a single layer without cascading UI changes.
Trade-offs: Introducing explicit layers (route → service → repository) adds boilerplate compared to a monolithic handler, but improves long-term maintainability and makes domain policies testable in isolation.
Clear boundaries
Routes handle auth + parsing, services enforce domain policies, repositories isolate persistence. Each layer has one job.
Shared contracts (SSOT)
Client and server share enums + schemas so API shapes stay stable. This reduces drift and makes refactors safer.
Deterministic behavior
Reporting and summaries are computed on the server with timezone-aware boundaries, so the UI remains consistent across devices.
Stable error semantics
Infrastructure errors are mapped into HTTP semantics to keep client handling consistent and avoid leaking implementation details.
Example: These principles are concretely implemented in the Transactions module, where EXPENSE / INCOME / SAVING are treated as domain-level concepts rather than simple enums, reinforced by canonical category keys and ownership validation.
Selected Code
excerptsShort excerpts that highlight the architectural boundaries and domain policies behind the Transactions system.
Shared contract (SSOT)
Client and server share the same enums and schemas to keep API boundaries stable.
// packages/shared/src/transactions/types.ts
export const RANGE_VALUES = ["THIS_MONTH","LAST_MONTH","THIS_YEAR","ALL"] as const;
export const rangeSchema = z.enum(RANGE_VALUES as any);
export const transactionCreateSchema = z.object({
type: z.enum(["EXPENSE","INCOME","SAVING"] as any),
amountMinor: z.number().int().nonnegative(),
category: z.string().min(1),
savingsGoalId: z.string().nullable().optional(),
occurredAtISO: z.string().datetime(),
});
Canonical categories
Aliases fold into stable server keys. SAVING forces a canonical category="savings".
// packages/shared/src/transactions/categories.ts
const ALIASES: Record<string, string> = {
restaurant: "dining",
transportation: "transport",
};
export function canonicalCategoryKeyForServer(raw: string, type?: string) {
const key = String(raw ?? "").trim().toLowerCase();
const folded = ALIASES[key] ?? key;
if (type === "SAVING") return "savings";
return folded || "other";
}
Route boundary
Routes stay thin: auth, parse, validate, then delegate to services.
// apps/server/src/app/api/transactions/route.ts
const auth = await requireUserId(request);
if (!auth.ok) return auth.response;
const parsedRange = rangeSchema.safeParse(rawRange.toUpperCase());
if (!parsedRange.success) return json400();
const data = transactionCreateSchema.parse(body);
return NextResponse.json(
await createTransactionForUser({ userId: auth.userId, data }),
{ status: 201 },
);
Domain rule: SAVING semantics
SAVING is a first-class type: requires savingsGoalId and enforces ownership validation.
// apps/server/src/domain/transaction/transaction.service.ts
if (type === "SAVING") {
if (!savingsGoalId) {
throw new HttpError(400, "savingsGoalId required");
}
await assertSavingsGoalOwnership({ userId, savingsGoalId });
category = "savings";
}
Timezone-aware reporting
Period ranges are computed on the server with timezone boundaries for deterministic summaries.
// apps/server/src/domain/transaction/transaction.service.ts
const { fromISO, toISO } = computeOccurredAtFilter({
range, // THIS_MONTH / LAST_MONTH / ...
timezone: user.timeZone,
});
return repo.listTransactions({
userId,
occurredAtGte: fromISO,
occurredAtLt: toISO,
});
Infrastructure semantics
Prisma errors are mapped into HTTP semantics to avoid leaking infrastructure details.
// apps/server/src/lib/prismaErrors.ts
return prismaHttpGuard(async () => {
return await prisma.transaction.update({
where: { id, userId },
data,
});
});
Client normalization
UI consumes one shape. Legacy fields and API drift are absorbed in the store.
// apps/mobile/src/app/store/transactionsStore.ts
const amountMinor =
typeof tx.amountMinor === "number" ? tx.amountMinor :
typeof tx.amountCents === "number" ? tx.amountCents : 0;
const occurredAtISO = tx.occurredAtISO ?? tx.occurredAt ?? "";
return { ...tx, amountMinor, occurredAtISO };
Transport rules (DTO normalize)
Canonical categories and partial-update safety are enforced before hitting the API.
// apps/mobile/src/app/api/transactionsApi.ts
function normalizeCreateDTO(input: any) {
return {
...input,
category: canonicalCategoryKeyForServer(input.category, input.type),
savingsGoalId: input.type === "SAVING" ? input.savingsGoalId : undefined,
};
}
Each excerpt points to a specific boundary: shared contract, category canonicalization, route, service policy, timezone filters, persistence semantics, client normalization, and transport rules.
These same architectural patterns are consistently applied across other modules such as Savings, Plans (period logic), and Dashboard aggregation, ensuring predictable behavior throughout the entire application.