|
| 1 | +# Architecture |
| 2 | + |
| 3 | +This page explains the design decisions behind the package collection. If you're looking for "how do I use this?" — start with [Getting Started](/getting-started). This page answers "why is it built this way?" |
| 4 | + |
| 5 | +## The Factory Pattern |
| 6 | + |
| 7 | +Every package exports a `createXxxService()` function that returns a plain object: |
| 8 | + |
| 9 | +```typescript |
| 10 | +const http = createHttpService("https://api.example.com"); |
| 11 | +const storage = createStorageService("myapp"); |
| 12 | +const loading = createLoadingService(); |
| 13 | +``` |
| 14 | + |
| 15 | +### Why not classes? |
| 16 | + |
| 17 | +Classes create hidden coupling. When you `new` a class, you commit to an inheritance chain, a `this` binding, and often a global singleton pattern. Our factories return plain objects — there's no prototype chain, no `this` to lose, no base class to accidentally override. |
| 18 | + |
| 19 | +```typescript |
| 20 | +// What you get back is just an object with methods |
| 21 | +const http = createHttpService("https://api.example.com"); |
| 22 | + |
| 23 | +// You can destructure it, pass individual methods around, or store it — no surprises |
| 24 | +const { getRequest, postRequest } = http; |
| 25 | +``` |
| 26 | + |
| 27 | +### Why not singletons? |
| 28 | + |
| 29 | +Singletons are convenient until you need two instances. A factory lets you create as many instances as you need: |
| 30 | + |
| 31 | +```typescript |
| 32 | +// Two HTTP services pointing at different APIs |
| 33 | +const apiHttp = createHttpService("https://api.example.com"); |
| 34 | +const authHttp = createHttpService("https://auth.example.com"); |
| 35 | + |
| 36 | +// Two storage services with different prefixes |
| 37 | +const userStorage = createStorageService("user"); |
| 38 | +const cacheStorage = createStorageService("cache"); |
| 39 | +``` |
| 40 | + |
| 41 | +In testing, factories make setup trivial — create a fresh service per test, no global state to reset. |
| 42 | + |
| 43 | +## Loose Coupling |
| 44 | + |
| 45 | +Packages avoid importing each other directly. Instead, they define the **shape** of what they need and accept anything that matches. |
| 46 | + |
| 47 | +### Structural Typing (Duck Types) |
| 48 | + |
| 49 | +The clearest example is `fs-theme`. It needs something that can `get()` and `put()` values — but it doesn't care whether that's `fs-storage`, a wrapper around IndexedDB, or a mock: |
| 50 | + |
| 51 | +```typescript |
| 52 | +// fs-theme defines what it needs |
| 53 | +interface ThemeStorageContract { |
| 54 | + get<T>(key: string): T | undefined; |
| 55 | + put(key: string, value: unknown): void; |
| 56 | +} |
| 57 | + |
| 58 | +// fs-storage happens to match — but theme doesn't import it |
| 59 | +const storage = createStorageService("myapp"); |
| 60 | +const theme = createThemeService(storage); // works because the shape matches |
| 61 | +``` |
| 62 | + |
| 63 | +::: tip What does this buy you? |
| 64 | +In tests, you can pass a plain object instead of a real storage service: |
| 65 | + |
| 66 | +```typescript |
| 67 | +const fakeStorage = { get: () => undefined, put: () => {} }; |
| 68 | +const theme = createThemeService(fakeStorage); |
| 69 | +``` |
| 70 | + |
| 71 | +No mocking library, no dependency injection framework. Just an object with the right shape. |
| 72 | +::: |
| 73 | + |
| 74 | +### Why peer dependencies? |
| 75 | + |
| 76 | +When packages do depend on each other (like `fs-loading` using `fs-http`'s middleware), the dependency is declared as a **peer dependency**. This means: |
| 77 | + |
| 78 | +1. Your application installs a single copy of each package |
| 79 | +2. Packages share the same instance at runtime |
| 80 | +3. There are no version conflicts from nested `node_modules` |
| 81 | + |
| 82 | +```json |
| 83 | +// fs-loading's package.json |
| 84 | +{ |
| 85 | + "peerDependencies": { |
| 86 | + "@script-development/fs-http": "^1.0.0", |
| 87 | + "vue": "^3.5.0" |
| 88 | + } |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +## Middleware Architecture |
| 93 | + |
| 94 | +Several packages support **middleware** — functions you register to intercept and react to events. Every middleware registration returns an **unregister function** for clean teardown. |
| 95 | + |
| 96 | +### The HTTP Middleware Pipeline |
| 97 | + |
| 98 | +`fs-http` provides three middleware hooks that form a request lifecycle: |
| 99 | + |
| 100 | +```typescript |
| 101 | +const http = createHttpService("https://api.example.com"); |
| 102 | + |
| 103 | +// 1. Before the request goes out |
| 104 | +const unregReq = http.registerRequestMiddleware((config) => { |
| 105 | + config.headers.set("Authorization", `Bearer ${token}`); |
| 106 | +}); |
| 107 | + |
| 108 | +// 2. When a successful response comes back |
| 109 | +const unregRes = http.registerResponseMiddleware((response) => { |
| 110 | + trackAnalytics(response.config.url, response.status); |
| 111 | +}); |
| 112 | + |
| 113 | +// 3. When a request fails |
| 114 | +const unregErr = http.registerResponseErrorMiddleware((error) => { |
| 115 | + if (error.response?.status === 401) { |
| 116 | + redirectToLogin(); |
| 117 | + } |
| 118 | +}); |
| 119 | + |
| 120 | +// Clean up when done |
| 121 | +unregReq(); |
| 122 | +unregRes(); |
| 123 | +unregErr(); |
| 124 | +``` |
| 125 | + |
| 126 | +### Cross-Package Middleware Composition |
| 127 | + |
| 128 | +The middleware system enables packages to compose without direct coupling. `fs-loading` doesn't modify `fs-http` — it hooks into it: |
| 129 | + |
| 130 | +```typescript |
| 131 | +import { createHttpService } from "@script-development/fs-http"; |
| 132 | +import { createLoadingService, registerLoadingMiddleware } from "@script-development/fs-loading"; |
| 133 | + |
| 134 | +const http = createHttpService("https://api.example.com"); |
| 135 | +const loading = createLoadingService(); |
| 136 | + |
| 137 | +// This registers request + response + error middleware on the HTTP service |
| 138 | +const { unregister } = registerLoadingMiddleware(http, loading); |
| 139 | + |
| 140 | +// Now loading.isLoading automatically reflects pending HTTP requests |
| 141 | +// When done, clean up all three middleware registrations at once: |
| 142 | +unregister(); |
| 143 | +``` |
| 144 | + |
| 145 | +The same pattern appears in `fs-dialog` (error middleware) and `fs-router` (navigation middleware). |
| 146 | + |
| 147 | +### Why the unregister pattern? |
| 148 | + |
| 149 | +In single-page applications, services outlive individual components. If a component registers middleware and then unmounts, those handlers must be cleaned up to prevent memory leaks and stale behavior: |
| 150 | + |
| 151 | +```typescript |
| 152 | +// In a Vue composable or component setup |
| 153 | +const unregister = http.registerResponseErrorMiddleware((error) => { |
| 154 | + showErrorNotification(error); |
| 155 | +}); |
| 156 | + |
| 157 | +// Clean up on unmount |
| 158 | +onUnmounted(() => { |
| 159 | + unregister(); |
| 160 | +}); |
| 161 | +``` |
| 162 | + |
| 163 | +## Component Agnosticism |
| 164 | + |
| 165 | +`fs-toast` and `fs-dialog` manage **state and lifecycle** — the queue, the stack, the open/close logic — but they don't render anything. You provide the Vue component, they handle the plumbing: |
| 166 | + |
| 167 | +```typescript |
| 168 | +import { createToastService } from "@script-development/fs-toast"; |
| 169 | +import MyToast from "@/components/MyToast.vue"; // YOUR component |
| 170 | + |
| 171 | +const toast = createToastService(MyToast); |
| 172 | + |
| 173 | +// Show a toast — props are type-checked against your component |
| 174 | +toast.show({ message: "Saved", type: "success" }); |
| 175 | +``` |
| 176 | + |
| 177 | +### Why not built-in components? |
| 178 | + |
| 179 | +Built-in UI components force design decisions on you — colors, animations, positioning, accessibility patterns. Our projects have different design systems. By separating the service (queue management, stack management, lifecycle) from the presentation (your component), each project gets the behavior without the opinions. |
| 180 | + |
| 181 | +The service provides a `ContainerComponent` that you mount once in your app root: |
| 182 | + |
| 183 | +```vue |
| 184 | +<template> |
| 185 | + <div id="app"> |
| 186 | + <router-view /> |
| 187 | + <toast.ToastContainerComponent /> |
| 188 | + <dialog.DialogContainerComponent /> |
| 189 | + </div> |
| 190 | +</template> |
| 191 | +``` |
| 192 | + |
| 193 | +The container handles rendering, ordering, and cleanup. Your components handle how things look. |
| 194 | + |
| 195 | +## Reactivity Model |
| 196 | + |
| 197 | +Vue-dependent packages use Vue's reactivity primitives (`Ref`, `ComputedRef`, `readonly`) directly — no wrapper layer, no custom observable pattern: |
| 198 | + |
| 199 | +```typescript |
| 200 | +const loading = createLoadingService(); |
| 201 | + |
| 202 | +loading.isLoading; // ComputedRef<boolean> — use in templates, watch, computed |
| 203 | +loading.activeCount; // DeepReadonly<Ref<number>> — readable but not writable |
| 204 | +``` |
| 205 | + |
| 206 | +This means services integrate naturally with Vue's ecosystem: |
| 207 | + |
| 208 | +```vue |
| 209 | +<script setup lang="ts"> |
| 210 | +import { watch } from "vue"; |
| 211 | +import { loading } from "@/services"; |
| 212 | +
|
| 213 | +// Standard Vue reactivity — nothing special |
| 214 | +watch(loading.isLoading, (isLoading) => { |
| 215 | + document.title = isLoading ? "Loading..." : "My App"; |
| 216 | +}); |
| 217 | +</script> |
| 218 | +``` |
| 219 | + |
| 220 | +### Why not Pinia? |
| 221 | + |
| 222 | +Pinia is a global state management solution. These packages are **service factories** — they create isolated instances with their own encapsulated state. The difference matters: |
| 223 | + |
| 224 | +- **Pinia store:** One global instance per store definition. Great for app-wide state. |
| 225 | +- **Service factory:** Create as many instances as needed. Great for domain-scoped state and testability. |
| 226 | + |
| 227 | +`fs-adapter-store` is the bridge — it provides the reactive state management pattern (like Pinia) but as composable, per-domain instances (like a factory). |
| 228 | + |
| 229 | +## Type Safety |
| 230 | + |
| 231 | +TypeScript isn't just for autocomplete — it catches entire categories of bugs at compile time. |
| 232 | + |
| 233 | +### Router Type Safety |
| 234 | + |
| 235 | +`fs-router` extracts route names from your route definitions and validates navigation calls: |
| 236 | + |
| 237 | +```typescript |
| 238 | +const routes = [ |
| 239 | + createCrudRoutes("/users", "users", Layout, { |
| 240 | + overview: UsersList, |
| 241 | + create: UserCreate, |
| 242 | + edit: UserEdit, |
| 243 | + }), |
| 244 | +]; |
| 245 | + |
| 246 | +const router = createRouterService(routes); |
| 247 | + |
| 248 | +router.goToEditPage("users", 42); // compiles — "users" exists and has an edit page |
| 249 | +router.goToEditPage("projects", 42); // compile error — "projects" is not a valid route |
| 250 | +``` |
| 251 | + |
| 252 | +### Translation Type Safety |
| 253 | + |
| 254 | +`fs-translation` validates keys against your translation schema: |
| 255 | + |
| 256 | +```typescript |
| 257 | +const translation = createTranslationService({ |
| 258 | + en: { |
| 259 | + common: { save: "Save", cancel: "Cancel" }, |
| 260 | + users: { title: "Users", empty: "No users found" }, |
| 261 | + }, |
| 262 | +}, "en"); |
| 263 | + |
| 264 | +translation.t("common.save"); // compiles — "common.save" exists |
| 265 | +translation.t("common.delete"); // compile error — "common.delete" doesn't exist |
| 266 | +``` |
| 267 | + |
| 268 | +## The Dependency Graph |
| 269 | + |
| 270 | +Packages form a directed graph with foundation packages at the bottom and domain packages at the top: |
| 271 | + |
| 272 | +``` |
| 273 | + ┌──────────────┐ ┌────────────┐ |
| 274 | + │ adapter-store│ │ router │ |
| 275 | + └──┬──┬──┬──┬─┘ └────────────┘ |
| 276 | + │ │ │ │ |
| 277 | + ┌──────────┘ │ │ └──────────┐ |
| 278 | + │ │ │ │ |
| 279 | + ┌──────┴───┐ ┌─────┴──┴──┐ ┌───────┴─────┐ |
| 280 | + │ helpers │ │ loading │ │ storage │ |
| 281 | + └──────────┘ └─────┬─────┘ └─────────────┘ |
| 282 | + │ |
| 283 | + ┌─────┴─────┐ |
| 284 | + │ http │ |
| 285 | + └───────────┘ |
| 286 | +
|
| 287 | + ┌────────┐ ┌────────┐ ┌─────────────┐ ┌───────┐ |
| 288 | + │ theme │ │ toast │ │ translation │ │dialog │ |
| 289 | + └────────┘ └────────┘ └─────────────┘ └───────┘ |
| 290 | +``` |
| 291 | + |
| 292 | +- **Foundation packages** (http, storage, helpers) have no Vue dependency |
| 293 | +- **Service packages** (theme, loading, toast, dialog, translation) depend on Vue and optionally on foundation packages |
| 294 | +- **Domain packages** (adapter-store, router) compose multiple services into higher-level patterns |
| 295 | +- **Cross-cutting:** theme uses structural typing to accept a storage-shaped object without importing fs-storage |
0 commit comments