Shipping archkit v0.1: a TypeScript Clean Architecture scaffolder built in one Claude Code session
I got tired of typing the same boilerplate every time I started a new TypeScript library.
Not src/index.ts tired — architect tired. Every new lib starts with two hours of "where does the domain go?", three Copy-Pasted Clean Architecture tutorials, and a folder structure that already looks wrong by the first commit.
So I built a scaffolder: npx @autosergach/archkit create my-lib. One command gives you a working TypeScript library with Clean Architecture baked in — domain, application, ports — plus a real example use case and 5 passing tests. Not "hello world". Not an empty console.log. A starting point you can actually extend.
This is a breakdown of how it works, how I built it, and the three things that almost killed the npm publish.
What you get from one command
npx @autosergach/archkit create my-lib
cd my-lib && pnpm install && pnpm test
# → 5 passing tests
The generated project looks like this:
my-lib/
├── src/
│ ├── domain/
│ │ └── User.ts # entity
│ ├── application/
│ │ └── CreateUser.ts # use case
│ └── ports/
│ └── UserRepository.ts # interface (port)
├── tests/
│ └── create-user.test.ts # InMemoryUserRepository, 5 tests
├── tsconfig.json # strict, ESM, Node ≥20
├── vitest.config.ts # vitest 3
├── eslint.config.mjs # eslint 9 flat config
└── package.json # ESM, exports, engines
Stack: strict TypeScript 5.7+, ESM-only, vitest 3.2, eslint 9 flat config. No legacy CJS, no .eslintrc.cjs, no any.
The createUser use case is the working example — it takes a UserRepository port (an interface), calls save(), and returns a domain User. The test creates an InMemoryUserRepository and runs the use case. This is the pattern you'd use for every use case you add.
It's opinionated on purpose: the dependency rule runs inward (domain knows nothing about infrastructure), the port lives at the boundary. If you've read Clean Architecture and wondered "how do I start?", this is the answer.
Why most scaffolders give you nothing
create-vite, oclif, plop — they all have different strengths, but they share one problem: they don't make an architectural decision.
You get an empty src/index.ts and a test runner and a README that says "good luck". If you already know where everything goes, that's fine. If you're starting a library from scratch and want to do it right — you have to bring the architecture yourself.
What I wanted: a scaffolder that has an opinion. One that generates a working system, not a blank canvas. Something a junior dev can clone and already see how the pieces connect.
That's archkit's bet: show you the pattern through a working example, not a README.
How archkit itself is built
archkit is a pnpm monorepo with two packages:
-
archkit-core— private, contains all the scaffolding logic -
@autosergach/archkit— published to npm, thin CLI that delegates to core
The key design decision: archkit-core uses a port/adapter pattern for the filesystem. There's a FileSystemPort interface:
interface FileSystemPort {
writeFile(path: string, content: string): Promise<void>
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>
exists(path: string): Promise<boolean>
}
In production, NodeFileSystemAdapter wraps fs/promises. In tests, InMemoryFileSystemAdapter keeps everything in a Map<string, string>. This means 35 of the 38 tests run without touching the disk — they're fast (< 1s total) and don't need cleanup.
The scaffolding pipeline is three stages:
const plan = buildInitPlan(options) // pure: compute what files to create
const rendered = renderTemplates(plan) // pure: fill in template variables
await executePlan(rendered, fs) // effectful: write via the port
buildInitPlan and renderTemplates are pure functions — easy to unit test. executePlan is the only function that needs a FileSystemPort, so it's the only one that varies between production and test.
For the CLI itself, I used cac for argument parsing and @inquirer/prompts for interactive questions (project name, output dir). tsup bundles archkit-core into the published package via noExternal: ['archkit-core'] — consumers get a single file, no transitive dependencies to worry about.
The session: from spec to npm in one day
I built v0.1.1 in a single Claude Code session. Here's the honest account.
It started with a brainstorming session — not "write me a CLI", but "help me think through the design". Port/adapter pattern for the filesystem: obvious in retrospect, but I needed to think it through before writing a line. I used context7 to pull fresh docs for cac, @inquirer/prompts, tsup, and vitest 3 — rather than relying on training data that might be a version behind.
The implementation was broken into 6 phases:
- Monorepo setup (pnpm workspace, tsconfigs, linting)
- Core domain —
FileSystemPort,InMemoryFileSystemAdapter - Template engine —
buildInitPlan,renderTemplates - CLI layer —
cacwiring, interactive prompts - E2E tests — real filesystem execution in tmpdir
- npm packaging and publish
9 commits, one per meaningful state. I committed each phase myself — that kept me in the loop and gave me clear checkpoints to revert to if something broke.
Total: ~6 hours real work. 38 tests, all green.
Three things that almost killed the publish
1. cac treats --no-X as a boolean pair
cac has a convention: if you declare --skip-install as an option, passing --no-skip-install automatically inverts it and sets the value to true (the opposite of what you'd expect).
In my unit tests, I was passing undefined for skipInstall when the flag wasn't provided. But cac was setting it to false when --no-skip-install wasn't explicitly passed. My tests were checking === undefined and breaking.
Fix: normalize the option to a boolean explicitly at the CLI boundary before passing it to core.
// before
const skipInstall = args.skipInstall // undefined in tests, false in CLI
// after
const skipInstall = args.skipInstall ?? false // always boolean
Small, but it caused 6 test failures that looked like a framework bug before I traced it back.
2. npm's similarity check is not the same as "is this name taken"
I tried to publish as archkit. npm rejected it — not because the name was taken, but because it was "too similar to an existing package". That package: arch-kit. Published in 2022, never updated, 3 downloads total.
npm has a similarity algorithm that runs on top of the name-availability check. If your name is too close to an abandoned package, you're blocked — even if npm info arch-kit shows it hasn't been touched in years.
Solution: scope it. @autosergach/archkit passed immediately. The tradeoff: users need npx @autosergach/archkit create instead of npx archkit create. A longer command, but the scoping also signals ownership clearly.
3. workspace:* in dependencies breaks consumers
In a pnpm monorepo, you reference internal packages with workspace:*. My initial @autosergach/archkit/package.json had archkit-core in dependencies:
{
"dependencies": {
"archkit-core": "workspace:*"
}
}
This works perfectly inside the monorepo — pnpm resolves it. But after npm publish, the published package has "archkit-core": "workspace:*" literally in its dependencies. When someone runs npm install @autosergach/archkit, npm tries to resolve workspace:* and fails.
The fix is moving archkit-core to devDependencies (since tsup bundles it) and adding noExternal: ['archkit-core'] to the tsup config. The bundled dist has no runtime reference to archkit-core — everything is inlined.
// tsup.config.ts
export default defineConfig({
entry: ['src/index.ts'],
noExternal: ['archkit-core'], // bundle core into the output
format: ['esm'],
})
This took me 20 minutes to debug — the error from npm install on the consumer side was cryptic.
The e2e test that made me confident
35 of my 38 tests use InMemoryFileSystemAdapter. They're fast, predictable, and cover all the template logic.
But I needed one test that validated the full chain: real filesystem, real pnpm install, real pnpm test. The kind of test that would have caught the workspace:* issue before publish.
// tests/e2e/create.test.ts
it('creates a project that installs and passes tests', async () => {
const tmpDir = path.join(os.tmpdir(), `archkit-e2e-${Date.now()}`)
await create({ name: 'my-lib', outDir: tmpDir })
const install = spawnSync('pnpm', ['install'], { cwd: tmpDir, stdio: 'pipe' })
expect(install.status).toBe(0)
const test = spawnSync('pnpm', ['test'], { cwd: tmpDir, stdio: 'pipe' })
expect(test.status).toBe(0)
// cleanup
rmSync(tmpDir, { recursive: true, force: true })
}, 60_000) // long timeout — pnpm install takes time
This test takes ~15 seconds (network permitting). It's slow enough that I don't run it on every file save, but it runs in CI on every commit. Having it meant I could change the template engine and verify the output still works end-to-end.
The InMemoryFileSystemAdapter tests handle coverage breadth. This test handles confidence depth.
What's next: v0.2
v0.2 has two targets:
NestJS + React fullstack template. The current template is library-oriented (no server, no client). A lot of real projects want a monorepo with a NestJS API and a React app wired up from day one. That template would include shared domain types, a port for the API layer, and a basic React component consuming a use case result.
--ai-ready flag. When passed, archkit generates CLAUDE.md, .claude/settings.json, and agents.md calibrated to the project structure. The idea: Claude Code gets a map of your project on day one, not after you've written 10k lines and finally added a CLAUDE.md. This is the feature I actually want for my own projects.
Try it
npx @autosergach/archkit create my-lib
cd my-lib && pnpm install && pnpm test
npm: npmjs.com/package/@autosergach/archkit
GitHub: github.com/autosergach/archkit
If you've hit the same problem — too much boilerplate before the first real line of code — try it and let me know what the generated structure is missing. What would you add to the template?
Originally published on my Hashnode blog. Follow me for more AI + Architecture content.













