Architecture
Hoikka follows a service-oriented architecture adapted for SvelteKit’s patterns. One codebase targets two runtimes — a Node.js server or Cloudflare Workers — selected with HOIKKA_TARGET.
Dual-target design
The platform-specific pieces are hidden behind two “seams” so the rest of the app never branches on the target:
- Database (
src/lib/server/db/index.ts) —dbis a lazyProxy. On node it resolves to abetter-sqlite3singleton (node.ts, migrates on first use). On Cloudflare it resolves to a Drizzle client over the D1 binding (platform.env.DB), cached per request onevent.locals. - Storage (
src/lib/server/storage/index.ts) — mirrors the db seam. When the R2 binding is present the request uses R2; otherwise files go to the local filesystem. Public URLs are/uploads/...on both.
svelte.config.js picks the adapter (adapter-node vs adapter-cloudflare) and vite.config.ts aliases node-only modules (better-sqlite3, node:fs storage, sharp images) to stubs on the Cloudflare build so they never enter the Workers bundle.
Request flow
Request → hooks.server.ts (session, customer sync, demo guard)
↓
+page.server.ts (thin controller: load + actions)
or *.remote.ts (query / command)
↓
services/*.ts (business logic)
↓
db/schema.ts (Drizzle ORM over SQLite / D1)
↓
+page.svelte (view)Service pattern
Business logic lives in singleton services under src/lib/server/services/:
class ProductService {
async getById(id: number) { ... }
async create(data: NewProduct) { ... }
async update(id: number, data: Partial<Product>) { ... }
}
export const productService = new ProductService();Route handlers are thin controllers that wire HTTP to services:
export const load = async ({ params }) => {
const product = await productService.getById(id);
return { product };
};
export const actions = {
update: async ({ request }) => {
const formData = await request.formData();
await productService.update(id, { name: formData.get("name") });
}
};Default language fields (e.g. product.name) are stored directly on entity tables. Non-default language translations are managed separately via TranslationService. See Localization for details.
Remote functions
Cart, wishlist, search, and checkout use SvelteKit remote functions in src/lib/remote/*.remote.ts (enabled via kit.experimental.remoteFunctions + compilerOptions.experimental.async). A query() reads data; a command() mutates and refreshes the query in the same roundtrip (single-flight mutation), so the client updates without extra requests.
Cookie cart
Shopping never writes to the database. The cart lives in a cookie (src/lib/server/cart-cookie.ts); the wishlist likewise (wishlist-cookie.ts). An order row is created only at checkout: the checkout page load reconciles the cart cookie into a draft order (orderService.startCheckout) tracked by a checkout_token cookie, and checkout command()s update that draft. Completion clears the cart cookies and returns the order code for navigation to the thank-you page.
Provider pattern
Payments and shipping use a provider pattern for easy swapping:
interface PaymentProvider {
code: string;
createPayment(order: Order): Promise<PaymentInfo>;
confirmPayment(transactionId: string): Promise<PaymentStatus>;
}
const PROVIDERS = new Map<string, PaymentProvider>([
["stripe", new StripeProvider()],
["mock", new MockProvider()]
]);Design principles
- Services are singletons — one instance shared across requests
- Route handlers are thin — validate input and call services
- Platform lives behind seams — the app never branches on
HOIKKA_TARGET - Views receive typed data — via
PageDatafrom load functions - Forms use progressive enhancement — work without JS via
use:enhance