@jeffrey2423/coding-standards 1.0.0 → 2.0.0
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/README.md +95 -174
- package/bin/cli.js +373 -20
- package/package.json +13 -3
- package/standards/backend/architecture/event-driven.md +112 -0
- package/standards/backend/architecture/microservice-anatomy.md +106 -0
- package/standards/backend/architecture/multitenancy.md +112 -0
- package/standards/backend/architecture/public-api-facade.md +112 -0
- package/standards/backend/architecture/shared-vs-owned.md +62 -0
- package/standards/{backend-standards.md → backend/backend-standards.md} +8 -1
- package/standards/{database-conventions.md → backend/database-conventions.md} +7 -0
- package/standards/backend/technology-stack.md +73 -0
- package/standards/core/ai-collaboration.md +64 -0
- package/standards/core/clean-architecture-ddd.md +69 -0
- package/standards/core/coding-conventions.md +66 -0
- package/standards/core/testing-strategy.md +46 -0
- package/standards/{mobile-flutter-standards.md → mobile/flutter/flutter-standards.md} +9 -1
- package/standards/{mobile-react-native-standards.md → mobile/react-native/react-native-standards.md} +9 -1
- package/standards/{technical-preferences-ux.md → web/_base/design-system-ux.md} +8 -1
- package/standards/web/_base/frontend-architecture.md +75 -0
- package/standards/{frontend-standards.md → web/_base/frontend-standards.md} +7 -0
- package/standards/web/_base/technology-stack.md +40 -0
- package/standards/web/microfrontends/module-federation-standard.md +216 -0
- package/standards/web/single-spa/single-spa-standard.md +196 -0
- package/standards/web/spa/spa-standard.md +53 -0
- package/standards/architecture-patterns.md +0 -444
- package/standards/technology-stack.md +0 -294
- package/standards/vite-config-standard.md +0 -531
package/bin/cli.js
CHANGED
|
@@ -1,35 +1,388 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// Interactive installer for the coding-standards library.
|
|
4
|
+
// Lets the user pick exactly the standards their project needs (by platform /
|
|
5
|
+
// architecture) and copies only those into ./coding-standards, then generates
|
|
6
|
+
// an INDEX.md so AI coding agents know which standards are active.
|
|
7
|
+
|
|
3
8
|
const fs = require("fs");
|
|
4
9
|
const path = require("path");
|
|
5
10
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
11
|
+
const STANDARDS_DIR = path.join(__dirname, "..", "standards");
|
|
12
|
+
const TARGET_DIR = path.join(process.cwd(), "coding-standards");
|
|
13
|
+
const MANIFEST = path.join(TARGET_DIR, ".standards-manifest.json");
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
// The exact flat file set shipped by v1.x. Used to clean up a v1 install on
|
|
16
|
+
// upgrade without touching the user's own root-level files.
|
|
17
|
+
const LEGACY_V1_FILES = new Set([
|
|
18
|
+
"architecture-patterns.md",
|
|
19
|
+
"backend-standards.md",
|
|
20
|
+
"database-conventions.md",
|
|
21
|
+
"frontend-standards.md",
|
|
22
|
+
"mobile-flutter-standards.md",
|
|
23
|
+
"mobile-react-native-standards.md",
|
|
24
|
+
"technical-preferences-ux.md",
|
|
25
|
+
"technology-stack.md",
|
|
26
|
+
"vite-config-standard.md",
|
|
27
|
+
]);
|
|
12
28
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
// ── Selectable architecture docs (backend, opt-in) ───────────────────────────
|
|
30
|
+
const ARCH_DOCS = [
|
|
31
|
+
{ id: "anatomy", file: "microservice-anatomy.md", label: "Microservice anatomy (layers, events)" },
|
|
32
|
+
{ id: "multitenancy", file: "multitenancy.md", label: "Multi-tenancy (RLS, tenant catalog)" },
|
|
33
|
+
{ id: "events", file: "event-driven.md", label: "Event-driven (outbox, sagas)" },
|
|
34
|
+
{ id: "api", file: "public-api-facade.md", label: "Public API facade (gateway, webhooks)" },
|
|
35
|
+
{ id: "shared", file: "shared-vs-owned.md", label: "Shared vs owned components" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// ── Resolve which directories/files to copy from a selection ─────────────────
|
|
39
|
+
function resolveSources(sel) {
|
|
40
|
+
const sources = []; // { from: absolute path, to: relative path under coding-standards }
|
|
41
|
+
|
|
42
|
+
// core is always included
|
|
43
|
+
sources.push({ dir: "core" });
|
|
44
|
+
|
|
45
|
+
if (sel.backend) {
|
|
46
|
+
// base backend docs
|
|
47
|
+
for (const f of ["backend-standards.md", "technology-stack.md", "database-conventions.md"]) {
|
|
48
|
+
sources.push({ file: path.join("backend", f) });
|
|
49
|
+
}
|
|
50
|
+
// opt-in architecture docs
|
|
51
|
+
for (const a of ARCH_DOCS) {
|
|
52
|
+
if (sel.arch.includes(a.id)) {
|
|
53
|
+
sources.push({ file: path.join("backend", "architecture", a.file) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (sel.web) {
|
|
59
|
+
sources.push({ dir: path.join("web", "_base") });
|
|
60
|
+
sources.push({ dir: path.join("web", sel.web) }); // spa | single-spa | microfrontends
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const fw of sel.mobile) {
|
|
64
|
+
sources.push({ dir: path.join("mobile", fw) }); // flutter | react-native
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return sources;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Copy helpers ─────────────────────────────────────────────────────────────
|
|
71
|
+
function listMarkdown(absDir) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
|
74
|
+
const abs = path.join(absDir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) out.push(...listMarkdown(abs));
|
|
76
|
+
else if (entry.name.endsWith(".md")) out.push(abs);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function copyFile(absFrom) {
|
|
82
|
+
const rel = path.relative(STANDARDS_DIR, absFrom);
|
|
83
|
+
const dest = path.join(TARGET_DIR, rel);
|
|
84
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
85
|
+
fs.copyFileSync(absFrom, dest);
|
|
86
|
+
return rel.split(path.sep).join("/");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function copySources(sources) {
|
|
90
|
+
const copied = [];
|
|
91
|
+
for (const s of sources) {
|
|
92
|
+
if (s.dir) {
|
|
93
|
+
const abs = path.join(STANDARDS_DIR, s.dir);
|
|
94
|
+
if (!fs.existsSync(abs)) continue;
|
|
95
|
+
for (const f of listMarkdown(abs)) copied.push(copyFile(f));
|
|
96
|
+
} else if (s.file) {
|
|
97
|
+
const abs = path.join(STANDARDS_DIR, s.file);
|
|
98
|
+
if (!fs.existsSync(abs)) continue;
|
|
99
|
+
copied.push(copyFile(abs));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...new Set(copied)].sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Upgrade / re-run cleanup ─────────────────────────────────────────────────
|
|
106
|
+
// Remove files this installer created on a previous run (tracked in the
|
|
107
|
+
// manifest) plus any v1 flat-layout leftovers, so upgrading from v1 or changing
|
|
108
|
+
// the selection never leaves stale, contradictory standards behind. Files the
|
|
109
|
+
// installer doesn't own are never touched.
|
|
110
|
+
function cleanPreviousInstall() {
|
|
111
|
+
if (!fs.existsSync(TARGET_DIR)) return { removed: 0, legacy: 0 };
|
|
112
|
+
let removed = 0;
|
|
113
|
+
let legacy = 0;
|
|
114
|
+
|
|
115
|
+
// 1. Files tracked by a previous v2 run.
|
|
116
|
+
if (fs.existsSync(MANIFEST)) {
|
|
117
|
+
try {
|
|
118
|
+
const prev = JSON.parse(fs.readFileSync(MANIFEST, "utf8"));
|
|
119
|
+
for (const rel of prev.files || []) {
|
|
120
|
+
const abs = path.join(TARGET_DIR, rel);
|
|
121
|
+
if (fs.existsSync(abs)) { fs.rmSync(abs); removed++; }
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* corrupt manifest — fall through to legacy detection */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. v1 leftovers: remove only the exact flat files v1 shipped — never the
|
|
129
|
+
// user's own root-level markdown.
|
|
130
|
+
for (const entry of fs.readdirSync(TARGET_DIR, { withFileTypes: true })) {
|
|
131
|
+
if (entry.isFile() && LEGACY_V1_FILES.has(entry.name)) {
|
|
132
|
+
fs.rmSync(path.join(TARGET_DIR, entry.name));
|
|
133
|
+
legacy++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
pruneEmptyDirs(TARGET_DIR);
|
|
138
|
+
return { removed, legacy };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pruneEmptyDirs(dir) {
|
|
142
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
143
|
+
if (!entry.isDirectory()) continue;
|
|
144
|
+
const sub = path.join(dir, entry.name);
|
|
145
|
+
pruneEmptyDirs(sub);
|
|
146
|
+
if (fs.readdirSync(sub).length === 0) fs.rmdirSync(sub);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeManifest(copied, sel) {
|
|
151
|
+
const data = {
|
|
152
|
+
version: require("../package.json").version,
|
|
153
|
+
selection: describeSelection(sel),
|
|
154
|
+
files: copied,
|
|
155
|
+
};
|
|
156
|
+
fs.writeFileSync(MANIFEST, JSON.stringify(data, null, 2) + "\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Front-matter parsing for INDEX generation ────────────────────────────────
|
|
160
|
+
function readFrontMatter(relPath) {
|
|
161
|
+
const abs = path.join(TARGET_DIR, relPath);
|
|
162
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
163
|
+
const m = text.match(/^---\s*([\s\S]*?)\s*---/);
|
|
164
|
+
const fm = { title: "", load_when: "" };
|
|
165
|
+
if (m) {
|
|
166
|
+
for (const line of m[1].split("\n")) {
|
|
167
|
+
const t = line.match(/^title:\s*(.+)$/);
|
|
168
|
+
const l = line.match(/^load_when:\s*"?(.+?)"?\s*$/);
|
|
169
|
+
if (t) fm.title = t[1].trim();
|
|
170
|
+
if (l) fm.load_when = l[1].trim();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!fm.title) {
|
|
174
|
+
// fall back to first H1 or filename
|
|
175
|
+
const h1 = text.match(/^#\s+(.+)$/m);
|
|
176
|
+
fm.title = h1 ? h1[1].trim() : path.basename(relPath, ".md");
|
|
177
|
+
}
|
|
178
|
+
return fm;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function generateIndex(copied, sel) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
lines.push("# Coding Standards — Active Set");
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push("> Generated by `@jeffrey2423/coding-standards`. These are the standards active in THIS project.");
|
|
186
|
+
lines.push("> AI agents: read this first, then load a standard on demand per its **Load when** trigger.");
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(`Selection: ${describeSelection(sel)}`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push("| Standard | File | Load when |");
|
|
191
|
+
lines.push("|---|---|---|");
|
|
192
|
+
for (const rel of copied) {
|
|
193
|
+
const fm = readFrontMatter(rel);
|
|
194
|
+
const lw = fm.load_when || "—";
|
|
195
|
+
lines.push(`| ${fm.title} | \`${rel}\` | ${lw} |`);
|
|
196
|
+
}
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push("## Precedence");
|
|
199
|
+
lines.push("");
|
|
200
|
+
lines.push("- A more specific platform/track doc overrides a general one.");
|
|
201
|
+
lines.push("- `MUST` overrides `SHOULD`. Surface real conflicts to the user instead of guessing.");
|
|
202
|
+
lines.push("");
|
|
203
|
+
fs.writeFileSync(path.join(TARGET_DIR, "INDEX.md"), lines.join("\n"));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function describeSelection(sel) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
if (sel.backend) parts.push(`backend (arch: ${sel.arch.length ? sel.arch.join(", ") : "none"})`);
|
|
209
|
+
if (sel.web) parts.push(`web/${sel.web}`);
|
|
210
|
+
if (sel.mobile.length) parts.push(`mobile/${sel.mobile.join("+")}`);
|
|
211
|
+
return parts.length ? parts.join("; ") : "core only";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Flag parsing (non-interactive mode) ──────────────────────────────────────
|
|
215
|
+
function parseFlags(argv) {
|
|
216
|
+
const flags = {};
|
|
217
|
+
for (const arg of argv) {
|
|
218
|
+
if (!arg.startsWith("--")) continue;
|
|
219
|
+
const [k, v] = arg.slice(2).split("=");
|
|
220
|
+
flags[k] = v === undefined ? true : v;
|
|
221
|
+
}
|
|
222
|
+
return flags;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function selectionFromFlags(flags) {
|
|
226
|
+
const all = flags.all === true;
|
|
227
|
+
const sel = { backend: false, web: null, mobile: [], arch: [] };
|
|
228
|
+
|
|
229
|
+
if (all) {
|
|
230
|
+
sel.backend = true;
|
|
231
|
+
sel.web = "microfrontends";
|
|
232
|
+
sel.mobile = ["flutter", "react-native"];
|
|
233
|
+
sel.arch = ARCH_DOCS.map((a) => a.id);
|
|
234
|
+
return sel;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (flags.backend) sel.backend = true;
|
|
238
|
+
if (typeof flags.web === "string") sel.web = flags.web;
|
|
239
|
+
else if (flags.web === true) sel.web = "spa";
|
|
240
|
+
if (typeof flags.mobile === "string") sel.mobile = flags.mobile.split(",").map((s) => s.trim());
|
|
241
|
+
|
|
242
|
+
if (sel.backend) {
|
|
243
|
+
if (flags["no-arch"]) sel.arch = [];
|
|
244
|
+
else if (typeof flags.arch === "string" && flags.arch !== "all") {
|
|
245
|
+
sel.arch = flags.arch.split(",").map((s) => s.trim()).filter((id) => ARCH_DOCS.some((a) => a.id === id));
|
|
246
|
+
} else {
|
|
247
|
+
sel.arch = ARCH_DOCS.map((a) => a.id); // default: all
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return sel;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasPlatformFlag(flags) {
|
|
254
|
+
return flags.all || flags.backend || flags.web !== undefined || flags.mobile !== undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Interactive mode (@clack/prompts) ────────────────────────────────────────
|
|
258
|
+
async function interactive() {
|
|
259
|
+
const p = await import("@clack/prompts");
|
|
260
|
+
p.intro("📐 coding-standards — pick what your project needs");
|
|
261
|
+
|
|
262
|
+
const platforms = await p.multiselect({
|
|
263
|
+
message: "What are you building? (space to toggle, enter to confirm)",
|
|
264
|
+
options: [
|
|
265
|
+
{ value: "backend", label: "Backend / API (.NET)" },
|
|
266
|
+
{ value: "web", label: "Frontend Web" },
|
|
267
|
+
{ value: "mobile", label: "Mobile" },
|
|
268
|
+
],
|
|
269
|
+
required: false,
|
|
270
|
+
});
|
|
271
|
+
if (p.isCancel(platforms)) cancel(p);
|
|
272
|
+
|
|
273
|
+
const sel = { backend: false, web: null, mobile: [], arch: [] };
|
|
274
|
+
sel.backend = platforms.includes("backend");
|
|
275
|
+
|
|
276
|
+
if (platforms.includes("web")) {
|
|
277
|
+
const web = await p.select({
|
|
278
|
+
message: "Web architecture:",
|
|
279
|
+
options: [
|
|
280
|
+
{ value: "spa", label: "SPA (single deployable)", hint: "default" },
|
|
281
|
+
{ value: "single-spa", label: "Single-SPA", hint: "mixed frameworks / hard isolation" },
|
|
282
|
+
{ value: "microfrontends", label: "Microfrontends (Module Federation)", hint: "license-gated, multi-product" },
|
|
283
|
+
],
|
|
284
|
+
initialValue: "spa",
|
|
285
|
+
});
|
|
286
|
+
if (p.isCancel(web)) cancel(p);
|
|
287
|
+
sel.web = web;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (platforms.includes("mobile")) {
|
|
291
|
+
const mobile = await p.multiselect({
|
|
292
|
+
message: "Mobile framework:",
|
|
293
|
+
options: [
|
|
294
|
+
{ value: "flutter", label: "Flutter" },
|
|
295
|
+
{ value: "react-native", label: "React Native" },
|
|
296
|
+
],
|
|
297
|
+
required: true,
|
|
298
|
+
});
|
|
299
|
+
if (p.isCancel(mobile)) cancel(p);
|
|
300
|
+
sel.mobile = mobile;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (sel.backend) {
|
|
304
|
+
const arch = await p.multiselect({
|
|
305
|
+
message: "Backend — distributed architecture standards (opt-in):",
|
|
306
|
+
options: ARCH_DOCS.map((a) => ({ value: a.id, label: a.label })),
|
|
307
|
+
initialValues: ARCH_DOCS.map((a) => a.id),
|
|
308
|
+
required: false,
|
|
309
|
+
});
|
|
310
|
+
if (p.isCancel(arch)) cancel(p);
|
|
311
|
+
sel.arch = arch;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return sel;
|
|
16
315
|
}
|
|
17
316
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
317
|
+
function cancel(p) {
|
|
318
|
+
p.cancel("Cancelled — nothing was written.");
|
|
319
|
+
process.exit(0);
|
|
21
320
|
}
|
|
22
321
|
|
|
23
|
-
|
|
322
|
+
// ── Help ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
function printHelp() {
|
|
324
|
+
console.log(`
|
|
325
|
+
coding-standards — copy modern, AI-ready coding standards into your project.
|
|
24
326
|
|
|
25
|
-
|
|
26
|
-
|
|
327
|
+
Usage:
|
|
328
|
+
npx @jeffrey2423/coding-standards interactive
|
|
329
|
+
npx @jeffrey2423/coding-standards [flags] non-interactive
|
|
330
|
+
|
|
331
|
+
Flags:
|
|
332
|
+
--backend include .NET backend standards
|
|
333
|
+
--web[=track] include web standards (track: spa | single-spa | microfrontends; default spa)
|
|
334
|
+
--mobile=flutter,react-native include mobile standards (comma-separated)
|
|
335
|
+
--arch=a,b,... | --no-arch backend architecture docs (default: all). ids: ${ARCH_DOCS.map((a) => a.id).join(", ")}
|
|
336
|
+
--all include everything
|
|
337
|
+
--yes, -y run non-interactively with whatever flags are given
|
|
338
|
+
--help, -h show this help
|
|
339
|
+
|
|
340
|
+
Examples:
|
|
341
|
+
npx @jeffrey2423/coding-standards --backend --web=microfrontends
|
|
342
|
+
npx @jeffrey2423/coding-standards --mobile=flutter --yes
|
|
343
|
+
npx @jeffrey2423/coding-standards --all
|
|
344
|
+
`);
|
|
27
345
|
}
|
|
28
346
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
|
|
347
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
348
|
+
async function main() {
|
|
349
|
+
const argv = process.argv.slice(2);
|
|
350
|
+
const flags = parseFlags(argv);
|
|
351
|
+
|
|
352
|
+
if (flags.help || argv.includes("-h")) {
|
|
353
|
+
printHelp();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let sel;
|
|
358
|
+
const nonInteractive = hasPlatformFlag(flags) || flags.yes || argv.includes("-y");
|
|
359
|
+
if (nonInteractive) {
|
|
360
|
+
sel = selectionFromFlags(flags);
|
|
361
|
+
} else {
|
|
362
|
+
sel = await interactive();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const sources = resolveSources(sel);
|
|
366
|
+
const upgrading = fs.existsSync(TARGET_DIR);
|
|
367
|
+
fs.mkdirSync(TARGET_DIR, { recursive: true });
|
|
368
|
+
|
|
369
|
+
// Idempotent: clear what we previously owned (and any v1 leftovers) first.
|
|
370
|
+
const { removed, legacy } = cleanPreviousInstall();
|
|
371
|
+
if (upgrading && (removed || legacy)) {
|
|
372
|
+
const note = legacy ? ` (incl. ${legacy} from a previous flat-layout v1 install)` : "";
|
|
373
|
+
console.log(`Existing install detected — removed ${removed + legacy} stale file(s)${note}.`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const copied = copySources(sources);
|
|
377
|
+
generateIndex(copied, sel);
|
|
378
|
+
writeManifest(copied, sel);
|
|
379
|
+
|
|
380
|
+
console.log(`\n✓ Copied ${copied.length} standard files to coding-standards/`);
|
|
381
|
+
console.log(`✓ Generated coding-standards/INDEX.md (selection: ${describeSelection(sel)})`);
|
|
382
|
+
console.log("\nNext: point your AI agent at coding-standards/INDEX.md (e.g. from AGENTS.md).");
|
|
34
383
|
}
|
|
35
|
-
|
|
384
|
+
|
|
385
|
+
main().catch((err) => {
|
|
386
|
+
console.error("\n✗ Installation failed:", err.message);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
});
|
package/package.json
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffrey2423/coding-standards",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Pick and copy modern, AI-ready coding standards and architecture guides into your project",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"coding-standards": "bin/cli.js"
|
|
8
8
|
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin/",
|
|
11
14
|
"standards/"
|
|
12
15
|
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@clack/prompts": "^0.7.0"
|
|
18
|
+
},
|
|
13
19
|
"keywords": [
|
|
14
20
|
"coding-standards",
|
|
15
21
|
"architecture",
|
|
16
22
|
"clean-architecture",
|
|
17
|
-
"ddd"
|
|
23
|
+
"ddd",
|
|
24
|
+
"microservices",
|
|
25
|
+
"microfrontends",
|
|
26
|
+
"ai-assisted",
|
|
27
|
+
"agents"
|
|
18
28
|
]
|
|
19
29
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Event-Driven Communication
|
|
3
|
+
platform: backend
|
|
4
|
+
track: distributed-architecture
|
|
5
|
+
load_when: "Coordinating microservices — outbox, idempotency, sagas, and correlation."
|
|
6
|
+
updated: 2026-06
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Event-Driven Communication
|
|
10
|
+
|
|
11
|
+
Microservices communicate by **asynchronous events** through a broker. Synchronous HTTP is reserved for when the caller needs the answer now.
|
|
12
|
+
|
|
13
|
+
> **The rule:** async when you can, sync when it hurts not to. Most microservice pain comes from abusing synchronous HTTP chains.
|
|
14
|
+
|
|
15
|
+
## Why async by default
|
|
16
|
+
|
|
17
|
+
| Sync HTTP chain `A→B→C→D` | Events via broker |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Temporal coupling — D down ⇒ A fails | B/C/D can be down without affecting A |
|
|
20
|
+
| Availability = product of each link | Broker absorbs spikes + retries |
|
|
21
|
+
| Latency = sum of the chain | Consumers process at their own pace |
|
|
22
|
+
|
|
23
|
+
**Use sync** only for "need it now" reads: validate stock before closing a sale, authenticate, check entitlement. If unsure, **start async** — converting async→sync later is easier than untangling broken chains.
|
|
24
|
+
|
|
25
|
+
## Transactional Outbox (mandatory)
|
|
26
|
+
|
|
27
|
+
Persisting the aggregate and publishing the event must be **atomic**. The integration event is written to an `outbox_messages` table **in the same transaction** as the aggregate change; a background worker publishes it to the broker.
|
|
28
|
+
|
|
29
|
+
```sql
|
|
30
|
+
BEGIN;
|
|
31
|
+
UPDATE orders SET status = 'confirmed' WHERE id = '…';
|
|
32
|
+
INSERT INTO outbox_messages (id, message_type, body, occurred_at)
|
|
33
|
+
VALUES ('…', 'OrderConfirmedV1', '{…}', now());
|
|
34
|
+
COMMIT;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Guarantees:
|
|
38
|
+
- DB fails before commit → nothing persisted, nothing published.
|
|
39
|
+
- Broker fails after commit → event stays in outbox, retried.
|
|
40
|
+
- **Never** any drift between persisted aggregate and published event. Eliminates the need for distributed transactions (2PC).
|
|
41
|
+
|
|
42
|
+
**Implementation:** use **Wolverine** (MIT) — it provides the Outbox natively. Avoid the now-commercial MediatR/MassTransit per the [open-source-only policy](../technology-stack.md). The pattern is what matters.
|
|
43
|
+
|
|
44
|
+
## Idempotency (mandatory)
|
|
45
|
+
|
|
46
|
+
The outbox + broker give **at-least-once** delivery, so the same event can arrive twice. Every consumer **MUST** be idempotent.
|
|
47
|
+
|
|
48
|
+
```sql
|
|
49
|
+
CREATE TABLE processed_messages (
|
|
50
|
+
message_id UUID PRIMARY KEY,
|
|
51
|
+
event_type VARCHAR(100),
|
|
52
|
+
tenant_id UUID,
|
|
53
|
+
processed_at TIMESTAMPTZ
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The consumer inserts `message_id` into `processed_messages` **in the same transaction** as the domain change; if `message_id` already exists, it skips. (Alternative for high volume: dedupe on a natural key — "`OrderClosedV1` for `order_id=X` processes once".)
|
|
58
|
+
|
|
59
|
+
## Sagas / process managers
|
|
60
|
+
|
|
61
|
+
A business process spanning multiple contexts is a **saga**, not an HTTP chain. The saga reacts to integration events, emits commands, persists its state, and uses **compensations** (not distributed rollback) on failure.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
on OrderConfirmedV1 → ReserveStockCommand (state: AWAITING_STOCK)
|
|
65
|
+
on StockReservedV1 → IssueInvoiceCommand (state: AWAITING_INVOICE)
|
|
66
|
+
on InvoiceIssuedV1 → … COMPLETED
|
|
67
|
+
on StockInsufficientV1→ CancelOrderCommand (compensation)
|
|
68
|
+
on InvoiceFailedV1 → ReleaseStockCommand + CancelOrderCommand
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A compensation is a **new business fact** ("order cancelled"), not a DB undo — the events happened and consumers reacted.
|
|
72
|
+
|
|
73
|
+
## Correlation ID
|
|
74
|
+
|
|
75
|
+
Every message and request carries a `CorrelationId` propagated end-to-end. With **OpenTelemetry**, the `traceparent` header does this automatically, giving a distributed trace across async hops. Without it, debugging async flows is impossible.
|
|
76
|
+
|
|
77
|
+
## Integration event shape
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"eventId": "evt_abc123",
|
|
82
|
+
"eventType": "OrderConfirmedV1",
|
|
83
|
+
"tenantId": "tenant_042",
|
|
84
|
+
"occurredAt": "2026-05-15T14:32:11Z",
|
|
85
|
+
"correlationId": "corr_xyz",
|
|
86
|
+
"data": { "orderId": "ord_001", "total": 125000, "currency": "COP" }
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Required: `eventId` (idempotency), `eventType` (versioned), `tenantId`, `occurredAt` (when it happened, not when published), `correlationId`, `data`.
|
|
91
|
+
|
|
92
|
+
> Consider the **CloudEvents 1.0** envelope (`id`/`source`/`type`/`specversion`) for cross-system event formatting; it's the CNCF standard and complements broker-internal events.
|
|
93
|
+
|
|
94
|
+
## Conventions
|
|
95
|
+
|
|
96
|
+
- **MUST** name events in the past tense with an explicit version: `OrderConfirmedV1`. Events are facts, not commands.
|
|
97
|
+
- **MUST** include `tenantId` in every event.
|
|
98
|
+
- **MUST** freeze a version's contract once published; breaking change ⇒ new version; both coexist during deprecation.
|
|
99
|
+
- **SHOULD** publish events for **business-significant state transitions**, not every property change. If unsure, don't publish — adding events later is easier than retiring them.
|
|
100
|
+
- **SHOULD NOT** dump the internal aggregate state into an event — design the event for its consumers.
|
|
101
|
+
|
|
102
|
+
## Sync calls (when necessary)
|
|
103
|
+
|
|
104
|
+
When you must call synchronously, call the other service's **public API** (never its DB). Apply: retries with exponential backoff, short timeout (≤ 5s), **circuit breaker**, propagate `X-Correlation-Id`, `Idempotency-Key` on idempotent POSTs. Use **Microsoft.Extensions.Resilience (Polly v8)**.
|
|
105
|
+
|
|
106
|
+
## Broker is internal
|
|
107
|
+
|
|
108
|
+
The broker (RabbitMQ/Kafka) is for **internal** service-to-service traffic only. External integrators receive **webhooks**, not broker access — see [`public-api-facade.md`](public-api-facade.md).
|
|
109
|
+
|
|
110
|
+
## Anti-patterns
|
|
111
|
+
|
|
112
|
+
- Long sync HTTP chains; events disguised as commands (`PleaseReserveStockEvent`); events without `tenantId`; non-idempotent consumers; per-property events; events leaking internal models; missing correlation ID.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Microservice Anatomy
|
|
3
|
+
platform: backend
|
|
4
|
+
track: distributed-architecture
|
|
5
|
+
load_when: "Designing or implementing a microservice — its project layout, layers, and event model."
|
|
6
|
+
updated: 2026-06
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Microservice Anatomy
|
|
10
|
+
|
|
11
|
+
Every microservice follows the **same** internal structure. Structural consistency lets the team move between services without relearning conventions, and lets operations use one toolset for all. Implements [`core/clean-architecture-ddd.md`](../../core/clean-architecture-ddd.md).
|
|
12
|
+
|
|
13
|
+
## The decisions
|
|
14
|
+
|
|
15
|
+
- **DB-per-service (real).** Each bounded context owns its database. **No other service touches it** — access is via API or events only. A shared DB is a distributed monolith.
|
|
16
|
+
- **Clean Architecture + DDD inside** each service (four layers, strict inward dependency rule).
|
|
17
|
+
- **Async by default** between services; sync only when the caller needs the answer now (see [`event-driven.md`](event-driven.md)).
|
|
18
|
+
- **Public, versioned contracts** at the edge (see [`public-api-facade.md`](public-api-facade.md)).
|
|
19
|
+
|
|
20
|
+
## Standard solution layout (5 projects)
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/
|
|
24
|
+
├── {Context}.Domain/ # aggregates, value objects, domain events — zero framework refs
|
|
25
|
+
├── {Context}.Application/ # use cases (commands/queries), DTOs, port interfaces
|
|
26
|
+
├── {Context}.Infrastructure/ # EF Core, repositories, outbox, consumers, ACL
|
|
27
|
+
├── {Context}.Api/ # Minimal API endpoints
|
|
28
|
+
└── {Context}.IntegrationEvents/ # published language — distributable package (NuGet)
|
|
29
|
+
├── V1/
|
|
30
|
+
└── V2/
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`IntegrationEvents` is the **only** project that crosses the context boundary; publish it as a versioned package so other services/adapters consume it.
|
|
34
|
+
|
|
35
|
+
### Dependency rule
|
|
36
|
+
|
|
37
|
+
- `Domain` → depends on nothing.
|
|
38
|
+
- `Application` → Domain.
|
|
39
|
+
- `Infrastructure` → Application + Domain (implements Application's interfaces).
|
|
40
|
+
- `Api` → Application.
|
|
41
|
+
- `IntegrationEvents` → primitives only.
|
|
42
|
+
|
|
43
|
+
> Swapping the DB engine, broker, or HTTP framework must touch **only Infrastructure**. The domain never finds out.
|
|
44
|
+
|
|
45
|
+
## Layer rules
|
|
46
|
+
|
|
47
|
+
**Presentation (Api).** Validates request format, authenticates (JWT + tenant claim), maps HTTP → Command/Query, returns the serialized result. **No business logic, no DB access, no calls to other services.**
|
|
48
|
+
- Path: `/api/v{version}/{context}/{resource}`; standard HTTP verbs/status; errors as Problem Details (RFC 9457).
|
|
49
|
+
- Reads headers: `Authorization: Bearer`, `Idempotency-Key`, `X-Correlation-Id`.
|
|
50
|
+
|
|
51
|
+
**Application.** CQRS use cases that **orchestrate**, never decide. A command handler: load aggregate → invoke its behavior → persist via Unit of Work → let domain events dispatch. Declares ports (`I{Aggregate}Repository`, `IUnitOfWork`, `IIntegrationEventOutbox`, `ITenantContext`). Holds the handler that **translates domain events → integration events**.
|
|
52
|
+
|
|
53
|
+
**Domain.** Pure language. Aggregates guard invariants and expose behavior (`Confirm()`, not setters); emit domain events; reference other aggregates by ID. Value objects validate on construction. Every invariant has a unit test including the violation case.
|
|
54
|
+
|
|
55
|
+
**Infrastructure.** Repository impls (one per aggregate root, load the whole aggregate, no `IQueryable` leaking out), ORM mappings, the **transactional outbox**, inbound **consumers** (idempotent), and the **Anticorruption Layer** that translates other contexts' models.
|
|
56
|
+
|
|
57
|
+
## Mediation / messaging (2026)
|
|
58
|
+
|
|
59
|
+
Per the [open-source-only policy](../technology-stack.md), use **Wolverine** (MIT) — it provides command/query mediation, the message bus, and the transactional Outbox in one package. Do **not** use the now-commercial MediatR/MassTransit. The `Mediator` source-generator or hand-rolled dispatch are also fine. The **pattern** (CQRS, domain-vs-integration events, outbox) matters, not the library.
|
|
60
|
+
|
|
61
|
+
## Domain events vs integration events
|
|
62
|
+
|
|
63
|
+
| Aspect | Domain Event | Integration Event |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Location | Domain layer | `IntegrationEvents` package |
|
|
66
|
+
| Audience | Inside the service | Other services + third parties |
|
|
67
|
+
| Language | Ubiquitous, internal | Public, versioned |
|
|
68
|
+
| Persistence | In memory | Outbox + broker |
|
|
69
|
+
| Versioning | none | strict (`V1`, `V2`, backward-compatible) |
|
|
70
|
+
| Example | `OrderConfirmedDomainEvent` | `OrderConfirmedV1` |
|
|
71
|
+
|
|
72
|
+
Explicit translation between them protects the domain: the internal model can evolve freely without breaking external consumers.
|
|
73
|
+
|
|
74
|
+
## Request flow
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
HTTP → JWT/tenant extracted → endpoint maps to Command → handler loads aggregate
|
|
78
|
+
→ aggregate applies rules + emits domain event → persist (aggregate + outbox row, one tx)
|
|
79
|
+
→ commit → domain event dispatched in-memory → translated to IntegrationEvent V1
|
|
80
|
+
→ (background) outbox worker publishes to broker → other services consume idempotently
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Naming
|
|
84
|
+
|
|
85
|
+
| Thing | Convention | Example |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| Bounded context | singular | `Catalog`, `Sales` |
|
|
88
|
+
| Aggregate | singular | `Order`, `Product` |
|
|
89
|
+
| Value object | descriptive | `Money`, `Sku` |
|
|
90
|
+
| Domain event | past + `DomainEvent` | `OrderConfirmedDomainEvent` |
|
|
91
|
+
| Integration event | past + version | `OrderConfirmedV1` |
|
|
92
|
+
| Command | imperative + `Command` | `ConfirmOrderCommand` |
|
|
93
|
+
| Query | `Get…Query` | `GetOrderByIdQuery` |
|
|
94
|
+
|
|
95
|
+
## Vertical slices
|
|
96
|
+
|
|
97
|
+
Clean Architecture layering and **Vertical Slice** organization combine well: keep the layer boundaries, but organize Application code by feature/use-case slice (command + handler + validator + DTO together) rather than by technical folder. This keeps related code cohesive and is the common 2026 default for new services.
|
|
98
|
+
|
|
99
|
+
## Anti-patterns
|
|
100
|
+
|
|
101
|
+
- Anemic CRUD-only service (no domain behavior) → it's a table with an API, not a microservice.
|
|
102
|
+
- Joins across service DBs → breaks DB-per-service; the context boundaries are probably wrong.
|
|
103
|
+
- Business rules in validators/endpoints → rules live in the domain.
|
|
104
|
+
- Long synchronous HTTP chains `A→B→C→D` → any link down tumbles the chain; go async.
|
|
105
|
+
- Shipping business code inside the `IntegrationEvents` package → it must contain only contracts.
|
|
106
|
+
- Forgetting idempotency in consumers → at-least-once delivery duplicates effects.
|