@donkeylabs/server 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/init.ts +201 -12
- package/cli/donkeylabs +6 -0
- package/context.d.ts +17 -0
- package/docs/api-client.md +520 -0
- package/docs/cache.md +437 -0
- package/docs/cli.md +353 -0
- package/docs/core-services.md +338 -0
- package/docs/cron.md +465 -0
- package/docs/errors.md +303 -0
- package/docs/events.md +460 -0
- package/docs/handlers.md +549 -0
- package/docs/jobs.md +556 -0
- package/docs/logger.md +316 -0
- package/docs/middleware.md +682 -0
- package/docs/plugins.md +524 -0
- package/docs/project-structure.md +493 -0
- package/docs/rate-limiter.md +525 -0
- package/docs/router.md +566 -0
- package/docs/sse.md +542 -0
- package/docs/svelte-frontend.md +324 -0
- package/package.json +12 -9
- package/registry.d.ts +11 -0
- package/src/index.ts +1 -1
- package/src/server.ts +1 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# Project Structure
|
|
2
|
+
|
|
3
|
+
This guide explains the canonical structure, naming conventions, and patterns for this framework. Following these conventions ensures consistency and maintainability.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Directory Layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
project-root/
|
|
11
|
+
├── core.ts # Plugin system, PluginManager, type helpers
|
|
12
|
+
├── router.ts # Route builder, handler registry
|
|
13
|
+
├── handlers.ts # TypedHandler, RawHandler, createHandler
|
|
14
|
+
├── middleware.ts # Middleware system, createMiddleware
|
|
15
|
+
├── server.ts # AppServer, HTTP handling
|
|
16
|
+
├── harness.ts # Test harness factory
|
|
17
|
+
├── index.ts # Main entry point (or server.ts)
|
|
18
|
+
│
|
|
19
|
+
├── context.d.ts # [GENERATED] Global context types
|
|
20
|
+
├── registry.d.ts # [GENERATED] Plugin/handler registry
|
|
21
|
+
│
|
|
22
|
+
├── core/ # Core services
|
|
23
|
+
│ ├── index.ts # Re-exports all services
|
|
24
|
+
│ ├── logger.ts # Logger service
|
|
25
|
+
│ ├── cache.ts # Cache service
|
|
26
|
+
│ ├── events.ts # Events service
|
|
27
|
+
│ ├── cron.ts # Cron service
|
|
28
|
+
│ ├── jobs.ts # Jobs service
|
|
29
|
+
│ ├── sse.ts # SSE service
|
|
30
|
+
│ └── rate-limiter.ts # Rate limiter
|
|
31
|
+
│
|
|
32
|
+
├── plugins/ # Plugin modules
|
|
33
|
+
│ ├── auth/
|
|
34
|
+
│ │ ├── index.ts # Plugin definition (REQUIRED)
|
|
35
|
+
│ │ ├── schema.ts # Generated DB types
|
|
36
|
+
│ │ └── migrations/ # SQL migrations
|
|
37
|
+
│ │ ├── 001_create_users.ts
|
|
38
|
+
│ │ └── 002_add_roles.ts
|
|
39
|
+
│ ├── orders/
|
|
40
|
+
│ │ ├── index.ts
|
|
41
|
+
│ │ ├── schema.ts
|
|
42
|
+
│ │ └── migrations/
|
|
43
|
+
│ └── ...
|
|
44
|
+
│
|
|
45
|
+
├── scripts/ # CLI and generation scripts
|
|
46
|
+
│ ├── cli.ts # Interactive CLI
|
|
47
|
+
│ ├── create-plugin.ts # Plugin scaffolding
|
|
48
|
+
│ ├── create-server.ts # Server scaffolding
|
|
49
|
+
│ ├── generate-registry.ts
|
|
50
|
+
│ ├── generate-types.ts
|
|
51
|
+
│ └── watch.ts
|
|
52
|
+
│
|
|
53
|
+
├── test/ # Test files
|
|
54
|
+
│ ├── core/ # Core service tests
|
|
55
|
+
│ ├── plugins/ # Plugin tests
|
|
56
|
+
│ └── integration.test.ts
|
|
57
|
+
│
|
|
58
|
+
├── docs/ # Documentation
|
|
59
|
+
│
|
|
60
|
+
├── package.json
|
|
61
|
+
├── tsconfig.json
|
|
62
|
+
└── CLAUDE.md # Framework documentation
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## File Naming Conventions
|
|
68
|
+
|
|
69
|
+
| Pattern | Example | Purpose |
|
|
70
|
+
|---------|---------|---------|
|
|
71
|
+
| `index.ts` | `plugins/auth/index.ts` | Plugin entry point |
|
|
72
|
+
| `schema.ts` | `plugins/auth/schema.ts` | Generated database types |
|
|
73
|
+
| `NNN_name.ts` | `001_create_users.ts` | Migrations (numbered) |
|
|
74
|
+
| `*.test.ts` | `auth.test.ts` | Test files |
|
|
75
|
+
| `*.d.ts` | `registry.d.ts` | Type declarations |
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Plugin Structure
|
|
80
|
+
|
|
81
|
+
Every plugin MUST follow this structure:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
plugins/<name>/
|
|
85
|
+
├── index.ts # Plugin definition (REQUIRED)
|
|
86
|
+
├── schema.ts # Database types (if using DB)
|
|
87
|
+
└── migrations/ # Migrations (if using DB)
|
|
88
|
+
├── 001_initial.ts
|
|
89
|
+
├── 002_add_column.ts
|
|
90
|
+
└── ...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Plugin index.ts Template
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// plugins/<name>/index.ts
|
|
97
|
+
import { createPlugin } from "../../core";
|
|
98
|
+
import type { DB } from "./schema"; // If using database
|
|
99
|
+
|
|
100
|
+
// Configuration type (if plugin is configurable)
|
|
101
|
+
interface MyPluginConfig {
|
|
102
|
+
option1: string;
|
|
103
|
+
option2?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const myPlugin = createPlugin
|
|
107
|
+
.withSchema<DB>() // Add if using database
|
|
108
|
+
.withConfig<MyPluginConfig>() // Add if configurable
|
|
109
|
+
.define({
|
|
110
|
+
name: "myPlugin",
|
|
111
|
+
dependencies: [], // Other plugins this depends on
|
|
112
|
+
handlers: {}, // Custom handlers
|
|
113
|
+
middleware: {}, // Custom middleware
|
|
114
|
+
|
|
115
|
+
// Main service factory
|
|
116
|
+
service: async (ctx) => {
|
|
117
|
+
// ctx.db - Database with your schema
|
|
118
|
+
// ctx.deps - Services from dependencies
|
|
119
|
+
// ctx.config - Your configuration
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
// Service methods exposed via ctx.plugins.myPlugin
|
|
123
|
+
myMethod: async () => { ... },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Routes Structure
|
|
132
|
+
|
|
133
|
+
Routes should be organized by domain:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
routes/
|
|
137
|
+
├── index.ts # Export all routers
|
|
138
|
+
├── users.ts # createRouter("users")
|
|
139
|
+
├── orders.ts # createRouter("orders")
|
|
140
|
+
└── admin.ts # createRouter("admin")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Route File Template
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// routes/users.ts
|
|
147
|
+
import { createRouter } from "../router";
|
|
148
|
+
import { z } from "zod";
|
|
149
|
+
|
|
150
|
+
export const usersRouter = createRouter("users")
|
|
151
|
+
.route("list").typed({
|
|
152
|
+
input: z.object({ page: z.number().default(1) }),
|
|
153
|
+
handle: async (input, ctx) => {
|
|
154
|
+
return ctx.plugins.users.list(input.page);
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
.route("get").typed({
|
|
159
|
+
input: z.object({ id: z.number() }),
|
|
160
|
+
handle: async (input, ctx) => {
|
|
161
|
+
return ctx.plugins.users.getById(input.id);
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
.middleware.auth({ required: true })
|
|
166
|
+
.route("create").typed({
|
|
167
|
+
input: z.object({ email: z.string().email(), name: z.string() }),
|
|
168
|
+
handle: async (input, ctx) => {
|
|
169
|
+
return ctx.plugins.users.create(input);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Do's and Don'ts
|
|
177
|
+
|
|
178
|
+
### DO: Use the Plugin System
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// GOOD: Business logic in plugin service
|
|
182
|
+
// plugins/orders/index.ts
|
|
183
|
+
service: async (ctx) => ({
|
|
184
|
+
async create(data: OrderData) {
|
|
185
|
+
const order = await ctx.db.insertInto("orders").values(data).execute();
|
|
186
|
+
await ctx.core.events.emit("order.created", order);
|
|
187
|
+
return order;
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Route handler just calls service
|
|
192
|
+
router.route("create").typed({
|
|
193
|
+
handle: async (input, ctx) => ctx.plugins.orders.create(input),
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
// BAD: Business logic in route handler
|
|
199
|
+
router.route("create").typed({
|
|
200
|
+
handle: async (input, ctx) => {
|
|
201
|
+
// 50 lines of business logic here...
|
|
202
|
+
const order = await ctx.db.insertInto("orders")...
|
|
203
|
+
// validation...
|
|
204
|
+
// event emission...
|
|
205
|
+
// etc...
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### DO: Use Core Services
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// GOOD: Use built-in services
|
|
214
|
+
ctx.core.logger.info("Order created", { orderId: order.id });
|
|
215
|
+
ctx.core.cache.set(`order:${id}`, order, 60000);
|
|
216
|
+
ctx.core.events.emit("order.created", order);
|
|
217
|
+
ctx.core.jobs.enqueue("sendOrderEmail", { orderId: order.id });
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// BAD: Roll your own
|
|
222
|
+
console.log("Order created:", order.id); // No structured logging
|
|
223
|
+
const cache = new Map(); // No TTL, no persistence
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### DO: Use Type-Safe Patterns
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// GOOD: Zod schemas for validation
|
|
230
|
+
router.route("create").typed({
|
|
231
|
+
input: z.object({
|
|
232
|
+
email: z.string().email(),
|
|
233
|
+
age: z.number().int().positive(),
|
|
234
|
+
}),
|
|
235
|
+
handle: async (input, ctx) => {
|
|
236
|
+
// input is fully typed
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// BAD: Manual validation
|
|
243
|
+
router.route("create").typed({
|
|
244
|
+
handle: async (input: any, ctx) => {
|
|
245
|
+
if (!input.email || !input.email.includes("@")) {
|
|
246
|
+
throw new Error("Invalid email");
|
|
247
|
+
}
|
|
248
|
+
// More manual checks...
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### DO: Use Middleware for Cross-Cutting Concerns
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// GOOD: Middleware
|
|
257
|
+
router.middleware
|
|
258
|
+
.auth({ required: true })
|
|
259
|
+
.rateLimit({ limit: 100, window: "1m" })
|
|
260
|
+
.route("protected").typed({ ... });
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// BAD: Duplicated in every handler
|
|
265
|
+
router.route("protected1").typed({
|
|
266
|
+
handle: async (input, ctx) => {
|
|
267
|
+
if (!ctx.user) throw new Error("Unauthorized");
|
|
268
|
+
// rate limit check...
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
router.route("protected2").typed({
|
|
272
|
+
handle: async (input, ctx) => {
|
|
273
|
+
if (!ctx.user) throw new Error("Unauthorized"); // Duplicated!
|
|
274
|
+
// rate limit check...
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### DO: Follow Naming Conventions
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// GOOD: Consistent naming
|
|
283
|
+
export const authPlugin = createPlugin.define({ name: "auth", ... });
|
|
284
|
+
export const usersRouter = createRouter("users");
|
|
285
|
+
|
|
286
|
+
// Plugin service methods: verb + noun
|
|
287
|
+
service: (ctx) => ({
|
|
288
|
+
createUser: async (data) => { ... },
|
|
289
|
+
getUserById: async (id) => { ... },
|
|
290
|
+
updateUser: async (id, data) => { ... },
|
|
291
|
+
deleteUser: async (id) => { ... },
|
|
292
|
+
})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
// BAD: Inconsistent naming
|
|
297
|
+
export const AUTH = createPlugin.define({ name: "Authentication", ... });
|
|
298
|
+
export const router = createRouter("Users");
|
|
299
|
+
|
|
300
|
+
service: (ctx) => ({
|
|
301
|
+
create: async (data) => { ... }, // Unclear what's being created
|
|
302
|
+
get: async (id) => { ... }, // Unclear what's being retrieved
|
|
303
|
+
})
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### DON'T: Create Unnecessary Files
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
// BAD: Over-engineered structure
|
|
310
|
+
plugins/auth/
|
|
311
|
+
├── index.ts
|
|
312
|
+
├── schema.ts
|
|
313
|
+
├── types/
|
|
314
|
+
│ ├── index.ts
|
|
315
|
+
│ ├── user.ts
|
|
316
|
+
│ ├── session.ts
|
|
317
|
+
│ └── token.ts
|
|
318
|
+
├── services/
|
|
319
|
+
│ ├── index.ts
|
|
320
|
+
│ ├── user.service.ts
|
|
321
|
+
│ └── auth.service.ts
|
|
322
|
+
├── handlers/
|
|
323
|
+
│ └── custom.handler.ts
|
|
324
|
+
├── middleware/
|
|
325
|
+
│ └── auth.middleware.ts
|
|
326
|
+
├── utils/
|
|
327
|
+
│ ├── hash.ts
|
|
328
|
+
│ └── token.ts
|
|
329
|
+
└── migrations/
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// GOOD: Keep it simple
|
|
334
|
+
plugins/auth/
|
|
335
|
+
├── index.ts // Everything in one file, or max 2-3
|
|
336
|
+
├── schema.ts
|
|
337
|
+
└── migrations/
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### DON'T: Edit Generated Files
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
// BAD: Editing generated files
|
|
344
|
+
// registry.d.ts - THIS IS GENERATED, DO NOT EDIT
|
|
345
|
+
|
|
346
|
+
// GOOD: Regenerate instead
|
|
347
|
+
bun run gen:registry
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### DON'T: Import Internals Directly
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// BAD: Importing internal implementation
|
|
354
|
+
import { someInternalFunction } from "./plugins/auth/internal";
|
|
355
|
+
|
|
356
|
+
// GOOD: Use plugin service
|
|
357
|
+
ctx.plugins.auth.publicMethod();
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Migration Patterns
|
|
363
|
+
|
|
364
|
+
### Creating Tables
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// plugins/<name>/migrations/001_create_orders.ts
|
|
368
|
+
import type { Kysely } from "kysely";
|
|
369
|
+
|
|
370
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
371
|
+
await db.schema
|
|
372
|
+
.createTable("orders")
|
|
373
|
+
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
|
|
374
|
+
.addColumn("userId", "integer", (col) => col.notNull())
|
|
375
|
+
.addColumn("total", "real", (col) => col.notNull())
|
|
376
|
+
.addColumn("status", "text", (col) => col.notNull().defaultTo("pending"))
|
|
377
|
+
.addColumn("createdAt", "text", (col) => col.notNull())
|
|
378
|
+
.execute();
|
|
379
|
+
|
|
380
|
+
await db.schema
|
|
381
|
+
.createIndex("orders_user_idx")
|
|
382
|
+
.on("orders")
|
|
383
|
+
.column("userId")
|
|
384
|
+
.execute();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
388
|
+
await db.schema.dropTable("orders").execute();
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Adding Columns
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
// plugins/<name>/migrations/002_add_shipping.ts
|
|
396
|
+
export async function up(db: Kysely<any>): Promise<void> {
|
|
397
|
+
await db.schema
|
|
398
|
+
.alterTable("orders")
|
|
399
|
+
.addColumn("shippingAddress", "text")
|
|
400
|
+
.execute();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function down(db: Kysely<any>): Promise<void> {
|
|
404
|
+
await db.schema
|
|
405
|
+
.alterTable("orders")
|
|
406
|
+
.dropColumn("shippingAddress")
|
|
407
|
+
.execute();
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Testing Patterns
|
|
414
|
+
|
|
415
|
+
### Plugin Unit Tests
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
// test/plugins/auth.test.ts
|
|
419
|
+
import { createTestHarness } from "../../harness";
|
|
420
|
+
import { authPlugin } from "../../plugins/auth";
|
|
421
|
+
|
|
422
|
+
describe("Auth Plugin", () => {
|
|
423
|
+
let harness: Awaited<ReturnType<typeof createTestHarness>>;
|
|
424
|
+
|
|
425
|
+
beforeEach(async () => {
|
|
426
|
+
harness = await createTestHarness(authPlugin);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(async () => {
|
|
430
|
+
await harness.cleanup();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("creates user", async () => {
|
|
434
|
+
const service = harness.manager.getServices().auth;
|
|
435
|
+
const user = await service.createUser({ email: "test@test.com" });
|
|
436
|
+
expect(user.email).toBe("test@test.com");
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Integration Tests
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
// test/integration.test.ts
|
|
445
|
+
import { createTestHarness } from "../harness";
|
|
446
|
+
import { authPlugin } from "../plugins/auth";
|
|
447
|
+
import { ordersPlugin } from "../plugins/orders";
|
|
448
|
+
|
|
449
|
+
describe("Integration", () => {
|
|
450
|
+
test("orders plugin uses auth", async () => {
|
|
451
|
+
const { manager } = await createTestHarness(ordersPlugin, [authPlugin]);
|
|
452
|
+
|
|
453
|
+
const auth = manager.getServices().auth;
|
|
454
|
+
const orders = manager.getServices().orders;
|
|
455
|
+
|
|
456
|
+
const user = await auth.createUser({ email: "test@test.com" });
|
|
457
|
+
const order = await orders.create({ userId: user.id, total: 100 });
|
|
458
|
+
|
|
459
|
+
expect(order.userId).toBe(user.id);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Checklist for New Features
|
|
467
|
+
|
|
468
|
+
When adding a new feature:
|
|
469
|
+
|
|
470
|
+
- [ ] Create plugin in `plugins/<name>/index.ts`
|
|
471
|
+
- [ ] Add database schema if needed (`migrations/`)
|
|
472
|
+
- [ ] Export plugin from index
|
|
473
|
+
- [ ] Run `bun run gen:registry`
|
|
474
|
+
- [ ] Add routes in `routes/<name>.ts`
|
|
475
|
+
- [ ] Register router in server
|
|
476
|
+
- [ ] Add tests in `test/`
|
|
477
|
+
- [ ] Run `bun test` to verify
|
|
478
|
+
- [ ] Run `bun --bun tsc --noEmit` to type check
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Common Mistakes to Avoid
|
|
483
|
+
|
|
484
|
+
1. **Putting business logic in route handlers** - Use plugin services
|
|
485
|
+
2. **Not using Zod validation** - Always validate input
|
|
486
|
+
3. **Editing generated files** - Regenerate instead
|
|
487
|
+
4. **Creating too many files** - Keep plugins simple
|
|
488
|
+
5. **Not running gen:registry** - Do this after any plugin change
|
|
489
|
+
6. **Manual auth/rate limit checks** - Use middleware
|
|
490
|
+
7. **Console.log for logging** - Use `ctx.core.logger`
|
|
491
|
+
8. **Rolling your own cache** - Use `ctx.core.cache`
|
|
492
|
+
9. **Duplicating code across routes** - Extract to plugin service
|
|
493
|
+
10. **Forgetting to emit events** - Use `ctx.core.events` for side effects
|