repository-architecture
Skill3 starsChanged 7 days agoMITView on GitHub
**WORKFLOW SKILL** - Language-agnostic repository architecture for injected class-based repositories that own database and external-system access.
- Reads credentials
In this repository6 other files it gives its agents.
- AGENTS.mdAGENTS.md
- agentic-uxSkill
- observability-and-envSkill
- promptable-uxSkill
- reference-tech-stackSkill
- tanstack-promptable-fullstack-app-templateSkill
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 5 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- could be automatic: a setting could enforce it
- Distinctive: line 50
Use this skill when application or pipeline code reaches into database collections, HTTP clients, or other external systems directly, or …
- Very specific: line 84
Keep the database connection or external client private to the composition root and implementations.
- Distinctive: line 87
Put calls to other external systems behind their own adapters/repositories too.
- Distinctive: line 221
If a child owns a resource, its parent disposes that child before closing its own resource.
- Very specific: line 277
Python: Use the context-manager protocol.
- Very specific: line 296
Other runtimes: Use that language's deterministic cleanup — C# IAsyncDisposable and await using, Go defer, Rust Drop — with the same split: …
- Very specific: line 301
Add collection owners and one domain facade.
- Distinctive: line 303
Test indexes, writes, filters, failures, and disposal with an in-memory repository.
- Distinctive: line 305
Search the change for leftover collection, driver, or raw client use outside repository modules.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Companion skills (install if missing)
This template publishes multiple skills. If only this skill is installed, add companions before related work:
tanstack-promptable-fullstack-app-template(parent) — TanStack architecture contract: schema layers, server functions, AI tools, and middleware-inferred request context. Install for route and tool wiring around a repository.npx skills add carlosvin/tanstack-fullstack-ai-template --skill tanstack-promptable-fullstack-app-templateobservability-and-env(companion) — Env parse, logger factories, and error-tracking bootstrap. Install when repository setup must read validated env instead ofprocess.env.npx skills add carlosvin/tanstack-fullstack-ai-template --skill observability-and-envreference-tech-stack(companion) — Opinionated packages for this template's reference app, including the database driver. Install when matching the demo stack.npx skills add carlosvin/tanstack-fullstack-ai-template --skill reference-tech-stack
Discover all skills: npx skills add carlosvin/tanstack-fullstack-ai-template --list
Repository architecture
Use this skill when application or pipeline code reaches into database collections, HTTP clients, or other external systems directly, or when a large repository needs smaller, cohesive collection-level responsibilities.
The repository structure is shown in TypeScript with a MongoDB-shaped driver. That driver is illustrative. Boundary checks and resource lifetime are restated for other languages. This template's concrete validator package is named in reference-tech-stack; this skill does not require it.
In this TanStack template, schema layers, server functions, and AI tools stay in the parent skill tanstack-promptable-fullstack-app-template. This skill owns how repository classes are split, injected, indexed, cleaned up, and disposed.
Skill routing
| Task | Load |
| Collection repositories, indexes, stale-data cleanup, resource lifetime, composition root | This skill |
| TanStack routes, schema layers, server functions, AI tools, auth ticket | tanstack-promptable-fullstack-app-template |
| Env schemas, logger factories, error-tracking bootstrap | observability-and-env |
| Which database driver or validator this template uses | reference-tech-stack |
| File paths in this repo | AGENTS.md §6 |
Boundary and ownership
UI / pipeline steps / CLI / AI tools
|
application API or orchestration
|
injected domain interfaces
|
domain repository implementation
|
collection repositories / external adapters
|
database driver / HTTP client- Business code asks for domain operations, not a driver, collection, cursor, query, bulk-write operation, or loader that exposes them. In web apps, route/AI layers call authenticated server functions; those functions invoke repositories. Pipeline steps may use injected repository interfaces directly.
- Define interfaces around consumers' use cases; implement them with classes. Construct concrete implementations at a composition root and inject interfaces into steps/services. Do not add
Db | Repositoryunions, runtime type detection, or backwards-compatibility adapters to perpetuate the old path. - Keep the database connection or external client private to the composition root and implementations. A repository method should express intent (for example
cleanupStale(runId, window)), never return a collection or expose an arbitrary query escape hatch. - Separate cohesive domains (for example inventory, billing, identity). Where one domain spans several collections, compose smaller collection repositories behind a domain facade. Each child owns its schema mapping, queries, writes, indexes, and relevant stale-data policy. Group tightly related collections when the same invariant spans them; do not force one class per table.
- The domain facade exposes readonly child interfaces if consumers genuinely need them, plus domain-level operations that coordinate them. It delegates rather than recreating driver queries. Avoid bypassing the facade to import concrete children into business code.
- Put calls to other external systems behind their own adapters/repositories too. An HTTP client that fetches records and a database repository that persists them have different responsibilities; do not bury orchestration or business calculations in either.
Contracts and data safety
- Accept immutable inputs (
readonly T[],Readonly<Options>, readonly properties, frozen models) and return domain shapes rather than driver documents. Validate untrusted payloads once, at the inbound boundary, before they reach repository methods. See Boundary validation. - Bind each collection once as a private constructor property. Only implementation modules and small implementation-private bulk helpers should use driver primitives.
- Each owner defines its own
createIndexes()alongside the queries requiring those indexes. The composition root invokes initialization; a domain facade delegates to its children. Index creation must be safe to repeat. Do not copy index specifications into CLI scripts or pipeline steps. - Put inserts, upserts, deletes, run-window filters, and stale-record cleanup in the owning repository. A step determines when a complete refresh has succeeded; the repository determines how records are swept. Scope destructive cleanup to the intended source and refresh window; never sweep after a failed, partial, or invalid extraction. Test the guard as well as the filter.
- Surface failures to the caller; do not replace a failed read with an empty success, silently skip failed writes, or catch broadly without rethrowing/reporting. An empty successful extract and a failed extract are not interchangeable.
Example: collection owner and domain facade
import type { Collection, Db } from 'mongodb'
interface IndexableRepository {
createIndexes(): Promise<void>
}
interface Snapshot {
readonly key: string
readonly source: string
readonly period: string
readonly sourceRunId: string
}
type CleanupScope = Readonly<Pick<Snapshot, 'source' | 'period' | 'sourceRunId'>>
interface SnapshotRepository extends IndexableRepository {
upsert(rows: readonly Readonly<Snapshot>[]): Promise<number>
cleanupStale(scope: CleanupScope): Promise<number>
}
class MongoSnapshotRepository implements SnapshotRepository {
private readonly collection: Collection<Snapshot>
constructor(db: Db) {
this.collection = db.collection<Snapshot>('snapshots')
}
async createIndexes(): Promise<void> {
await this.collection.createIndex({ source: 1, period: 1, key: 1 }, { unique: true })
}
async upsert(rows: readonly Readonly<Snapshot>[]): Promise<number> {
if (rows.length === 0) return 0
const result = await this.collection.bulkWrite(
rows.map(row => ({
updateOne: {
filter: { source: row.source, period: row.period, key: row.key },
update: { $set: row },
upsert: true,
},
}))
)
return result.upsertedCount + result.modifiedCount
}
async cleanupStale(scope: CleanupScope): Promise<number> {
const result = await this.collection.deleteMany({
source: scope.source,
period: scope.period,
sourceRunId: { $ne: scope.sourceRunId },
})
return result.deletedCount
}
}
interface InventoryRepository extends IndexableRepository {
readonly snapshots: SnapshotRepository
}
class MongoInventoryRepository implements InventoryRepository {
readonly snapshots: SnapshotRepository
constructor(db: Db) {
this.snapshots = new MongoSnapshotRepository(db)
}
async createIndexes(): Promise<void> {
await this.snapshots.createIndexes()
}
}The caller invokes cleanupStale only after successfully fetching, validating, and upserting the complete source/period. Index and sweep predicates must agree on the source/period partition.
Avoid: loader.collection('snapshots').drop(), db.collection('snapshots').createIndex(...) in a pipeline step, or a facade that forwards getCollection() to callers.
Boundary validation
Repository methods take domain values. Untrusted payloads — HTTP bodies, driver documents, files, queue messages — become those values once, at the ingestion boundary. A failed check fails the extract. An empty successful extract and a failed extract stay distinct.
- Runtime validators are the usual tool where the type system does not observe external data. TypeScript types are erased at runtime. Python annotations are not enforced unless something checks them. Parse with one library and keep the parsed value. TypeScript examples: Zod, Valibot, ArkType (
parse/safeParse). Python examples: Pydantic (model_validate), msgspec. One library per program. - In strongly typed languages, the domain type is usually the contract inside the program. A parallel schema library is less common once a value has been decoded. Decode bytes into that type at the boundary; a failed decode fails the extract. Put invariants the compiler cannot see (non-blank keys, closed enums, ranges) in the constructor or decoder. Add a runtime validator only for constraints the type still cannot express — Jakarta Bean Validation on Java, JSON Schema, or the language's equivalent.
// TypeScript. Types are erased, so the boundary needs a runtime validator.
// Zod: SnapshotSchema.parse(row). Valibot: v.parse(SnapshotSchema, row). ArkType: snapshotType(row).
const snapshot: Snapshot = parseSnapshot(untrustedRow)# Python. Pydantic checks the payload at runtime. A type checker does not.
from pydantic import BaseModel, ConfigDict
class Snapshot(BaseModel):
model_config = ConfigDict(frozen=True)
key: str
source: str
period: str
source_run_id: str
snapshot = Snapshot.model_validate(untrusted_row)// Java. The record is the contract. Jackson, another decoder, or a hand-written
// parser is the boundary. Bean Validation is optional, for rules the type cannot state.
public record Snapshot(String key, String source, String period, String sourceRunId) {
public Snapshot {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("key");
}
}
}
Snapshot snapshot = mapper.readValue(json, Snapshot.class);The same decode-into-the-type idea is serde in Rust, encoding/json into a struct in Go, and System.Text.Json into a record in C#. After parseSnapshot, model_validate, or readValue succeeds, pass that value to upsert. Parse again inside the repository only when the repository itself reads the untrusted driver document.
Resource lifetime is not data cleanup
- Use
AsyncDisposableandawait usingwhen the TypeScript target/runtime support explicit resource management. The scope that owns a connection closes it with[Symbol.asyncDispose](); borrowedDb, collections, and injected clients must not close the shared connection. Do not require no-op disposal methods on every child merely for symmetry. - If a child owns a resource, its parent disposes that child before closing its own resource. Decide whether siblings can be disposed concurrently; preserve the original failure while still attempting necessary cleanup. On initialization failure, dispose already-acquired resources before rethrowing.
cleanupStale(...)deletes persisted records as part of a successful sync;[Symbol.asyncDispose]()releases connections and other owned resources on scope exit. Never perform stale-data deletion in a disposer.
import type { Db, MongoClient } from 'mongodb'
class RepositoryScope implements AsyncDisposable {
readonly inventory: InventoryRepository
constructor(private readonly client: MongoClient, db: Db) {
this.inventory = new MongoInventoryRepository(db)
}
async [Symbol.asyncDispose](): Promise<void> {
await this.client.close()
}
}
await using repositories = await createRepositoryScope(config)
await syncInventory(repositories.inventory)The factory should connect, construct, and initialize repositories; if initialization fails, it must close the owned client before propagating the failure. Where await using is unavailable, use the equivalent try/finally with the same ownership rules.
Same ownership in other languages
| Concern | TypeScript | Java | Python |
| Who closes the connection | The scope that owns the client, via [Symbol.asyncDispose]() | The scope that owns the client, via AutoCloseable.close() | The scope that owns the client, via __exit__ or __aexit__ |
| Caller syntax | await using | try-with-resources | with / async with |
| Borrowed handle | Must not close the shared client | Must not close the shared client | Must not close the shared client |
| Stale rows | cleanupStale(...) after a successful full refresh | Same method; never inside close() | Same method; never inside __exit__ / __aexit__ |
Java. Implement AutoCloseable (or Closeable) on the owning scope and close the client in close(). Try-with-resources is the caller syntax. A child that owns a resource is closed before the parent closes its own. Initialization failure still closes what was already acquired.
final class RepositoryScope implements AutoCloseable {
final InventoryRepository inventory;
private final MongoClient client;
RepositoryScope(MongoClient client, InventoryRepository inventory) {
this.client = client;
this.inventory = inventory;
}
@Override
public void close() {
client.close();
}
}
try (RepositoryScope repositories = createRepositoryScope(config)) {
syncInventory(repositories.inventory);
}Python. Use the context-manager protocol. Synchronous owners implement __enter__ and __exit__; asynchronous owners implement __aenter__ and __aexit__. Returning a false-y value from the exit method preserves the original failure. Do not delete persisted rows from either magic method.
class RepositoryScope:
def __init__(self, client, inventory):
self._client = client
self.inventory = inventory
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self._client.close()
return False
async with await create_repository_scope(config) as repositories:
await sync_inventory(repositories.inventory)Other runtimes. Use that language's deterministic cleanup — C# IAsyncDisposable and await using, Go defer, Rust Drop — with the same split: the disposer releases connections and file handles; a repository method sweeps stale records only after a successful refresh.
Migration and verification workflow
- Find driver, loader, index, cleanup, and connection-close call sites. Name the system and collection that owns each one.
- Add collection owners and one domain facade. The composition root constructs them with
newand callscreateIndexes(). - Inject that interface into callers. Remove driver imports from routes and tools.
- Test indexes, writes, filters, failures, and disposal with an in-memory repository. Run the app locally. Do not commit credentials.
Search the change for leftover collection, driver, or raw client use outside repository modules. The composition root is the expected exception.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

