Best Practices
These are field-tested conventions for modeling with ArchLang — how to choose granularity, when to aspect versus nest, how to assign ownership, and the anti-patterns to steer around. They grow out of real diagrams. Treat them as recommendations that survive contact with a real system, starting with the philosophy that everything else follows from.
Philosophy
Section titled “Philosophy”It’s an architecture language, not a drawing tool
Section titled “It’s an architecture language, not a drawing tool”ArchLang is not a drawing or diagramming language. It is an architecture language — a text-based way to describe your architecture. Diagrams, views, and layouts all derive from that text; they are secondary, as they should be.
So optimize for truth, not looks. When you type, don’t think about how the diagram will render. Ask whether the model is complete, and whether it is true to what actually exists. Layout is the renderer’s job; your job is an accurate, complete description. A pretty diagram of a wrong model is worthless; a plain diagram of a correct model is the goal.
Staleness is a process problem, not a language feature
Section titled “Staleness is a process problem, not a language feature”ArchLang does not try to solve diagram drift. That’s out of scope, by design.
Auto-deriving the real wiring is a different class of tool. If you want to know what actually connects to what, derive it from runtime — tracing, a service mesh like Istio — not from ArchLang. The language is for reasoning about and developing architecture further: the forward-looking, strategic side, not a passive mirror of current state.
Keeping the model fresh is up to your processes. Use whatever enterprise and development practices you already have; the language solves none of them, so those practices don’t belong here.
Agents and tools — including the Claude skills shipped with ArchLang — can help keep things current, but today they’re weak at building processes. That work needs deep business-domain understanding, and agents work better against code than against business processes. Useful aid, not a solution.
Architecture is for everyone, not just architects
Section titled “Architecture is for everyone, not just architects”The goal is to bring architecture to the whole team, not to make a specialist artifact. It’s mostly used by technical people, yes, but it isn’t limited to system architects or analysts.
The mission is to make architecture simple, understandable, and approachable — and to distinguish technical detail from business-important detail. That’s part of what aspects do, separating the business plane from the data plane so a reader can focus on the layer they care about. A QA engineer should be able to open the model and reason about it. A product owner should be able to understand what is built and reason about its constraints and limitations, even without grasping every technical nuance of why it’s built that way.
The implication for how you model: favor clarity a non-specialist can follow. Keep the business plane legible, and push technical mechanics onto aspects and nested detail so they don’t drown the high-level story. If only an architect can read it, it’s missing the point.
Authorship is distributed and bottom-up
Section titled “Authorship is distributed and bottom-up”ArchLang does not require a system architect to write it. The best author of any given piece is whoever owns that piece. The developer who maintains a microservice describes it better than an architect would, and keeps it more current — they may not see the whole system, but they know their part cold. A system analyst often has a far better grasp of the business processes than an architect, so they should own the processes.
The recommended adoption is to distribute ownership: make each person responsible for documenting the things they own. This yields documentation that is more accurate and more current than a model where one lone architect has to go model everything.
The flow is bottom-up. People who build write the descriptions, and that accumulates into the true system. Architects and senior engineers — who have the time to understand the whole picture — then read that true system to reason about it, argue about it, and decide strategic directions. Bottom-up authorship gives the top a better vantage point.
Modeling
Section titled “Modeling”Authoring is a two-pass, mostly mindless process
Section titled “Authoring is a two-pass, mostly mindless process”The language is built to minimize thinking while you document an existing architecture. Save your thinking for reasoning about and changing it — when you’re just expressing what already exists, authoring should feel almost mechanical.
The recommended workflow for capturing an existing system has two passes.
In pass one, document each module in isolation. Go module by module and describe what each one is and can do. Don’t think about connections yet. Be as thorough or as lazy as you like — bare, empty modules are acceptable if you want to move fast. One concern only: each thing on its own.
In pass two, document each process. Switch your brain into process mode and walk through each business process in order: how the business steps map to the software things that actually happen. This is where connections emerge — you describe the sequence, and the interactions fall out of it.
The result is an architecture, without ever sitting down to ask “what interacts with what.” That question is the hard one, and you never answer it directly — because that’s not how software is actually built. You think about what each thing can do, then about what the business does. Connections are a byproduct of processes, not something you design up front. The process is arguably more important than the module: modules are the cast, processes are the story, and the story is what the business actually is.
Process-first drafting — let the skeleton fall out
Section titled “Process-first drafting — let the skeleton fall out”The two-pass flow above is module-first: describe the cast, then write the story. You can also run it the other way — process-first — which often matches how you actually think about a new system: what happens before what exists.
Just write the processes. Reference modules and interfaces that don’t exist yet — Customer > Orders.createOrder when there is no Orders module and no createOrder interface. Instead of erroring, the compiler synthesizes them as stubs (drawn dashed) and tracks each gap as a TODO — “missing detail,” not “wrong.” Your flow draws immediately, and the module skeleton appears underneath it.
Then promote the stubs: go to each dashed module and define it for real — description, type, fields, nesting. As you do, its TODO clears. “What’s left to define” becomes a measurable count, not a pile of suppressed errors, and tooling can gate “ready to propose” on zero TODOs.
Module-first suits documenting an existing system, where you already know the cast. Process-first suits sketching a new or proposed one, or distributed authorship — an analyst sketches the processes and the module skeleton appears for engineers to flesh out. They’re mirror images of the same two activities, so pick the entry point that matches what you know first. Both converge on the same model.
Pick the right granularity — depth costs maintenance
Section titled “Pick the right granularity — depth costs maintenance”You can nest modules infinitely — a module per Java class if you wanted. You shouldn’t. The deeper you model, the harder it is to keep alive: code is fluid, so the architecture is always going stale. Every level of detail is something you must go back and update, and you probably won’t. Match depth to the scale and complexity of the system, and no more.
Architecture is not programming. A module does not have to map onto your program structure. Don’t think about classes, interfaces-versus-impls, or technical packages when authoring — those are programming concerns. Module-per-class is the wrong default: overly specific and unmaintainable. (The niche exception is ultra-high-assurance “rocket” architecture where literally everything must be documented — not real-world business software.)
Every element can carry a description — use it. This is not a blanket “always prefer prose to nesting.” The point is that when submodules would be too granular or too simple to be worth it, it’s fine to stop early and capture that detail in the description instead. A good description can beat exploding a service into trivial submodules — but only when those submodules wouldn’t earn their keep. Nest when nesting carries real structure; describe when it wouldn’t.
Calibrate by architecture style:
- Microservices — a single service as a root-level node is completely fine. Often it’s better to describe the service well than to split it into submodules. With many microservices, it’s fine to leave them undetailed (description only) — but do go down to the per-microservice level.
- Service-oriented architecture (not “super micro”) — each service does several things, so multiple modules inside a service is fine.
- Monolith — multiple modules inside, even multi-nested, is completely okay.
The most granular recommended floor is one module per lowest-level domain package, and only if you do domain-driven packaging. “Lowest-level package” means a domain package, not a technical one — an interfaces/impls split is not domain-driven. For example, a feature is the lowest-level module; one microservice might hold three closely-related features, giving three submodules. Stop there. Don’t go finer than the lowest domain unit.
Two axes: nesting is domain depth, aspects connect planes
Section titled “Two axes: nesting is domain depth, aspects connect planes”One mental model sits behind most modeling decisions. Module nesting is domain nesting: putting B inside A means B belongs to A’s domain — a sub-part of that thing, not a peer it talks to. An aspect connection is a connection across planes of architecture: it links a module to something on a different plane of thinking — the data plane, the hosting plane, the messaging plane — not to a peer on the same plane.
So before placing anything, ask whether this is a sub-part of a domain (nest it) or a different plane the thing lives on (aspect it). Everything below is an application of this one distinction.
Encapsulate single-owner resources; shared ones are siblings
Section titled “Encapsulate single-owner resources; shared ones are siblings”The classic textbook drawing — a row of service boxes, each wired to its own database cylinder — is wrong in ArchLang.
If one service owns one database, the database is a technical detail inside the service’s domain. Nest it as a submodule of the service; don’t draw it as a sibling box connected by an edge. The domain is the service; the DB is encapsulated by it.
The deciding question is ownership, not type. A database used by exactly one microservice nests inside that microservice. A database shared across multiple microservices is a peer concern, so it sits at the same level as them. Don’t end up with a board of services all fanning out to databases — a single-owner DB belongs inside its owner.
Two ways to use descriptions — pick per element
Section titled “Two ways to use descriptions — pick per element”Descriptions support full Markdown. There are two valid modes; use whichever fits each element.
The first mode is a short one-liner: explain what the thing does in more detail than the name alone, and nothing more. Don’t restate the obvious — skip what the name, the nested components, the processes, or the interfaces already say. A redundant description is worse than none: for a PaymentProcessor, “processes payments” is worse than an empty description. No description at all is completely fine when there’s nothing non-obvious to add.
The second mode is a longer doc. Since Markdown is supported, write as much as you want — service internals, an API overview, design notes. A real document can live here. You may also use it as a link-dump for extra connections.
But structured links belong in fields, not the description. A link to external documentation — a Confluence page, say — is a property, and properties go in fields. Use a field for “the doc lives here,” and reserve the description for prose. (See Link out — make the architecture a links hub.)
Ownership is first-class — assign teams
Section titled “Ownership is first-class — assign teams”Ownership is a first-class concept; use it. A module can carry a team property naming its owner or maintainer.
It’s optional, not universal. Not every module needs a team — a fine-grained internal module often doesn’t, but an owned microservice does. Assign teams where ownership is meaningful. Ownership propagates by nesting: if a parent is owned by a team, its nested children are assumed to belong to that team unless they say otherwise, so you mark ownership at the level it actually changes rather than on every node. You can also set a team at the root of the space to claim that everything belongs to that team by default — a clean baseline you then override where ownership diverges.
Aim for no orphans. Strive for every meaningful piece to be owned, directly or by propagation. A module owned by nothing is a smell.
Related: distributed, bottom-up authorship — owners author their own pieces.
Model the business layer, not the transport plumbing
Section titled “Model the business layer, not the transport plumbing”When services talk over infrastructure — Kafka, a message broker, a service mesh — model the logical call between them, not the hop through the infrastructure.
Don’t draw the data plane as nodes in the call path:
- ❌ Service A → Kafka broker → Service B
This buries the architecture that matters — who depends on whom — under transport mechanics, and every service ends up pointing at the same broker node, so the diagram says nothing.
Instead, draw the direct logical dependency and attach the infrastructure as an aspect on the interface:
- ✅ Service A calls the
kafkainterface on Service B, and that interface carries abrokeraspect pointing at akafka-brokermodule.
The broker becomes a materialized aspect — present in the model, but living on a different plane than the service call graph. The data plane is data, not a routing waypoint.
This wins on several counts. Edges show real dependencies between services, so the architecture stays readable. Infrastructure stays expressible without polluting the primary view. And you can toggle the broker’s aspect layout on demand to see which connections use which broker — the data plane reveals itself only when you ask for it.
The same pattern generalizes to infrastructure as separate layers of existence: message brokers, network-zone separation, logs, metrics. Each is its own plane, overlaid as an aspect layer rather than wired into the service call graph. Model the system once at the business layer, then reveal each infrastructure concern as a toggleable layer on top.
The hosting plane chains the same way. A nested database isn’t the bottom — it sits on a stack of deployment planes, each linked by an aspect, not by nesting. A database (nested in its service) carries an aspect naming the DBMS it runs on, such as a specific Postgres instance; that DBMS in turn carries an aspect naming the server, VM, or cluster it’s deployed on. Each hop is a different plane of architecture, so each is an aspect connection — exactly like the Kafka broker aspect. Nesting would wrongly imply “part of the domain”; aspects correctly say “lives on” or “deployed on.”
Related: fields and aspects — a string aspect value defines a classification identity that builds an overlay web across the model.
Plain modules and interfaces are fine by default — typing is sugar
Section titled “Plain modules and interfaces are fine by default — typing is sugar”The module is the base building block, and typing it is sugar, added only when you need it. Not everything needs a custom type. A plain module is the default building block; a plain interface is the default module connection. Reach for them first — the language is designed so they’re enough.
Don’t invent do-nothing types. Inside a service you don’t need a custom, behaviorless service block wrapping a plain module — just use the plain module. A bare type that adds nothing is noise.
Add a custom type only when it earns its keep — when you want to subtype things, attach custom widgets, define custom requirements, add custom fields, or explicitly assert that something is a particular type (where the statement itself is the value). Otherwise: plain module as the building block, plain interface as the connection. Usually that’s all you need.
Model as many processes as possible — they prove modules aren’t dead
Section titled “Model as many processes as possible — they prove modules aren’t dead”Build lots of processes. Ideally, model all of them — this matters more than it looks.
Detail is optional; coverage is not. You don’t need full branching, looping, and try/catch in every process — treat that control-flow detail as sugar. A shallow process still counts.
Coverage matters because processes document the model’s connections. A module that appears in no process is, by definition, alone — not cooperating with anything, connected to nothing. That’s effectively dead code: a piece with no link to the real world. And that’s almost never true. A module with no process usually means you forgot to model a process, not that the module is genuinely dead. So missing processes are a signal: go find the process that module participates in and model it.
There’s one exception — omit the obvious, but only when it’s nested. Some interactions are so implied they only add noise. A microservice saving to its own encapsulated database is the canonical case: you don’t write “service saves to DB” as a step. Treat the microservice as the actor and let the nested database be the obvious detail it carries. The DB still earns its place in the model — it documents structure — it just needn’t appear in the process.
The test is encapsulation, not “feels obvious.” You may omit a thing only when it’s completely nested inside the actor you do mention. Don’t omit a high-level peer just because “everything uses it”:
- ✅ Omit a service’s own nested database — talk to the service.
- ❌ Don’t omit a gateway. A gateway is a high-level idea (“all things go through the gateway”), not an encapsulated detail. Skipping it in a process can hide policy violations, since the gateway is exactly where policy lives.
Encapsulation also enables abstraction-level drafts. Because nested detail is omittable, you can write a process where large high-level systems talk to each other without naming the specific services inside them — a legitimate, fast draft. The caveat is that it stays a draft: lower-level description is usually better, because the real world may not behave as the high-level sketch assumes. And even in a draft, think in domains — skipping a gateway is allowed but discouraged (policy risk), while skipping nested detail is fine.
Prefer nesting, but don’t force a single root module
Section titled “Prefer nesting, but don’t force a single root module”Group things into submodules rather than piling them at the root — but only when it makes sense. Nest, don’t over-nest. Reach for grouping into submodules instead of adding everything to the root flat.
But don’t invent an artificial single root module either. You should not wrap everything in one system module that incorporates the whole world. The root is fine as the root: a single-system architecture legitimately lives at the root with no umbrella node.
Keep processes local to the parts they belong to
Section titled “Keep processes local to the parts they belong to”Declare a process at the smallest scope that contains it. A process entirely within one service is a local process on that service. A process that spans multiple services, where you have a system module, is declared on the system. A process that spans multiple services where your system is the root — a single-system architecture with no umbrella module — can be a root process.
The rule is locality: a process lives with whatever owns the span it covers, and the root counts as a legitimate owner when the architecture is single-system.
Extract reusable subprocesses
Section titled “Extract reusable subprocesses”A recurring sequence of steps belongs in a reusable subprocess, invoked with do <name>(args). Factor out anything you’d otherwise repeat across processes.
Arguments are intent-only — not type-checked or bound. Passing one documents “this subprocess probably needs this,” so use them to make the call site readable, not to enforce anything. Forward references are fine: you may do a subprocess that doesn’t exist yet, and the gap is a TODO (a stub), not an error — the same incomplete-by-design rule as process-first drafting. Define it later. You can also export a subprocess as a cross-space entry point: an exported subprocess lets another space invoke behavior without seeing its internal steps — pair this with gateways for controlled cross-space calls.
Aspects, fields & descriptions
Section titled “Aspects, fields & descriptions”These are the three ways to annotate an element. The crisp distinction: aspects are implicit, cross-plane connections; fields are structured data; descriptions are unstructured data.
Aspects — cross-cutting connectivity across planes
Section titled “Aspects — cross-cutting connectivity across planes”From a pure language-design view, an aspect looks close to a field — you could model one as a single field whose value is an object holding stuff. That’s not the right way to think about it. Aspects exist for cross-cutting connectivity, not for storing data.
When multiple elements carry an aspect with the same key and the same value, they’re declared to share a place on a plane of existence. The key is a plane — a separate plane of existence, like network-zone. The value is a place on that plane, like dmz, internal, or vpc. Elements sharing key and value are connected there; these are classifications. A classification represents weak belonging more than a hard edge — “these things live in the same zone” or “use the same broker.”
You can promote a plane into real modeled elements: make modules for each network segment, compose them, and write processes to declare network policies between them. Now you have a separate system — a network plane — and aspects become the connections from the business-logic plane to that network plane. These are materialized aspects — memberships at real modules, not just shared classifications.
Interfaces can carry aspects too. It’s fine for an interface to connect to aspects or modules — for example, a service interface carrying an aspect for the Kafka broker it uses. The Kafka broker is an aspect: it does not belong to the business-service diagram; it lives on the infrastructure plane, alongside the DBMS. You can then read that interface as a Kafka topic in that broker.
See also: Modeling → Model the business layer, and Two axes: nesting is domain depth, aspects connect planes.
Fields — structured data, not connectivity
Section titled “Fields — structured data, not connectivity”A field holds structured data you explicitly don’t want interconnected. Use fields for per-element structured properties — the version of a service, the status of a service; for required fields, structured data mandated on all instances of a type; for widget controls, since fields drive widgets; and for any custom structured data that should stay local to the element rather than become a cross-plane connection.
The rule of thumb: if it’s a property of the thing, use a field; if it’s a connection to another plane, use an aspect. (External doc links are properties, so they’re fields — see Two ways to use descriptions.)
Descriptions — unstructured data
Section titled “Descriptions — unstructured data”Prose. Markdown. See Two ways to use descriptions for how to use them well.
File & workspace structure
Section titled “File & workspace structure”Think in domains, top to bottom
Section titled “Think in domains, top to bottom”The overarching principle is to organize by domain at every level. Domains are the primary axis of structure — not element type, not technical layer. Folders group by domain. Files are smaller domains: subdomains, features, slices. The whole workspace should read as a hierarchy of domains.
When in doubt about how to split or place something, ask what domain it belongs to — the domain answer is the recommended one. The entries below are specific applications of this single idea.
Organize files by domain, not by element type
Section titled “Organize files by domain, not by element type”Group .arch files around domains, not around the kind of thing they contain.
Don’t split files by element type:
- ❌
processes.arch - ❌
types.arch - ❌
views.arch
Name files after the domain — or a subdomain, feature, or slice — they describe:
- ✅
<domain-name>.arch
A domain file holds everything belonging to that domain together — modules, interfaces, types, views, processes — so a domain reads as one coherent unit.
Splitting is fine when it’s still domain-scoped. You can pull a subset into its own file, but name it for the domain and concern, never for the bare element type:
- ✅
business-primitives.arch— custom business types used across modules - ✅
<domain>-<typename>.arch— types for a specific domain - ✅
network-security-views.arch— views in their own file when the file is a coherent lens over a domain
The test is whether a file is a piece of a domain or a purposeful lens, not a dumping ground for one syntactic category. network-security-views.arch passes because it’s a tool focused on a concern; views.arch fails because it’s just “all the views.”
Default to a single root space; nest spaces only for enterprise divisions
Section titled “Default to a single root space; nest spaces only for enterprise divisions”A space is a visibility boundary, but the meaningful axis for nesting it is ownership — who works on the domain, not how the system decomposes technically.
The default is one space at the root, which is also a package — keep it a monorepo. For the overwhelming majority of projects, a single root space is the right answer. You can have multiple, but usually you don’t.
Nest spaces only when ownership genuinely diverges. A nested space earns its place when a different set of people owns and works on it, and the domain is very large and closed — not closed in a system-architecture sense, but in an organizational one, like enterprise divisions that barely interact. The typical trigger is a really large company with multiple domains backed by strong, separate domain leaders who don’t really collaborate. That org-level separation is what nested spaces express.
This is about nested spaces, not nested packages — a different concern. Spaces draw the ownership and visibility lines; don’t reach for them to model technical layering.
Keep types close to their instances; a shared types package is the escape hatch
Section titled “Keep types close to their instances; a shared types package is the escape hatch”By default, hold types in the same namespace as — and domainly close to — their instances. A type lives near the modules that use it.
The escape hatch for large orgs is a dedicated types package nested inside a package. When you’re a large company — whether you run one very large namespace or several — you can put shared types in their own package inside a package. That package is isolated (opaque, exporting only what it chooses), which is fine, and you can then use it from anywhere you need those types. This buys clean separation: shared vocabulary lives in one owned, isolated place, and the rest of the model depends on it explicitly rather than duplicating or scattering type definitions.
Dependencies & imports
Section titled “Dependencies & imports”Prefer explicit use; avoid use * from a package
Section titled “Prefer explicit use; avoid use * from a package”By default, import explicitly — pick and choose. Treat a package as a palette and take the specific types you need, not the whole thing.
Don’t pull everything from a large package:
- ❌
use * from store.backend
A use * drags in a flood of types you don’t use and couples you to the package’s entire surface.
Gather your own working set explicitly instead:
- ✅
use postgres, kafka from store.backend
Assemble the abstractions you actually want by name, and you end up with your own curated library rather than the package’s full inventory.
use * is acceptable for small libraries, especially company-internal custom ones — you wrote it and you want all of it. But be strict with the standard library: always treat it as a pick-and-choose palette, and never use * the stdlib.
The standard library is optional batteries — build your own when you need to
Section titled “The standard library is optional batteries — build your own when you need to”The stdlib is batteries included, not mandatory. Use it freely, but you’re completely free to build your own library from the ground up. It’s a nice-to-have on top of a bare language, aiming to be a good, exhaustive default set for architecture.
To get started, lean on stdlib items and swap them later without worry — starting on stdlib doesn’t lock you in. A large company may want its own library, often not about widgets (stdlib widgets are reusable) but about org-specific semantics: maintainer and ownership requirements, department or division relationships, and the like. And extending beats replacing — you can always extend a stdlib item with extra requirements or relating options rather than rewriting it. The library mechanism is straightforward, so authoring one is low-cost; if your company needs it, do it.
Think of a library as potentially a meta-model. The language ships a fundamental model — universal and useful — but a library can be more than convenience: it can add restrictions and guidelines that drive how you think. Through the nice-to-have lens, a library is a bundle of handy widgets and models, like arch.extras with its actor and group items that make a diagram more vivid. Through the meta-model lens, a library imposes a notation or discipline — C4 notation as an ArchLang library is very much a meta-model, and adopting it shapes how you decompose and reason. Pick a library for what it does to your thinking, not just for the components it hands you.
Declare imports at the space level so they’re space-wide
Section titled “Declare imports at the space level so they’re space-wide”By default, use things in the space — the package.archspace manifest — itself, so the imported palette is space-wide and available everywhere. Build the palette in one place and reuse it reliably across the whole space, rather than re-importing the same types file by file. Local, very small, one-off needs can import closer to use, but the bias is toward a single space-level declaration.
The reason is discoverability: the space manifest becomes the explicit list of what’s available to everyone working in it. Someone can open the space and see the whole palette at a glance. The UI offers a type viewer for this, but the code itself should also be self-documenting, and an explicit space-level use list is that documentation.
Single-file drafts are encouraged
Section titled “Single-file drafts are encouraged”Building a real architecture means a package, central management, the full setup. But a single standalone .arch file is a first-class, encouraged way to start — perfect for a quick draft. File-local imports exist precisely to support this.
The one constraint is that a standalone file can only depend on locally defined types and standard-library types, not on custom package types. There’s no package-linking syntax for a single-file definition, so cross-package custom types are out of reach. That’s fine for drafts: the standard library is vast and useful, and you can get a long way modeling a quick idea against stdlib plus local types before promoting the draft into a managed package.
Naming & IDs
Section titled “Naming & IDs”IDs must be meaningless and permanent
Section titled “IDs must be meaningless and permanent”An ID must never change. That is the entire point of an ID: even when the model’s meaning changes — renamed, re-scoped, moved — the ID stays fixed. The ID is the stable identity; everything else is mutable.
Therefore IDs must be meaningless. An ID should not embed a domain name, a type, or any other semantic content, because anything meaningful eventually becomes wrong, and then you’re tempted to change the ID — which you must not. Aim for a truly random ID.
Rule. Never mutate an ID, for any reason.
Tooling does this for you: autocomplete generates a truly random ID, so prefer the tool whenever possible — hand-randomizing is hard. If you must type an ID manually and can’t do better, it’s acceptable to use <filename>-<index> or <filename>-<random-postfix>, with the filename as the main piece. The caveat is that the module may later move to a different file, so a filename-based ID will go stale as a name — but that’s fine, because the ID is meaningless anyway and never changes regardless of where the module lives. Stale-looking is acceptable; mutating the ID is not. This is a fallback because IDs are hard, not the ideal — if you can find a better way to get randomness by hand, do.
Everything should have an ID in real architecture
Section titled “Everything should have an ID in real architecture”IDs being optional is a convenience for quick drafts, not a license to skip them long-term. For proper, sound, long-term architecture, every element has a stable ID — modules, interfaces, types, processes, views.
A useful mental model: when you propose changes, things should have IDs. If it’s part of the durable model people review and evolve, it has an identity that survives renames and moves. Skip IDs only while sketching.
Naming case conventions
Section titled “Naming case conventions”- Modules →
UpperCamelCase(e.g.OrderService). - Types →
lower_snake_case(e.g.order_id,payment_method). - Interfaces →
lowerCamelCase(e.g.placeOrder,getById). - Filesets →
UpperCamelCase. - Processes →
UpperCamelCase(e.g.Checkout,OrderFulfilment). - Packages →
lower.dotted.segments, lowercase and dot-separated.- Publishing types? Use the reverse company domain prefix (e.g.
com.acme.payments) so the name is a globally-unique address. This is only needed for packages whose types you intend to publish or share. - Your own repository? Just a company or project name, no domain prefix (e.g.
acme.paymentsorpayments). That’s fine. - Stdlib packages keep their existing roots (
arch.cloud.aws,arch.extras).
- Publishing types? Use the reverse company domain prefix (e.g.
- Spaces → also dot-notated, continuing the package name. A space is conceptually a sub-package of its root package, so its name extends the root’s dotted, domain-separated path (e.g. root
acme→ spaceacme.retailbanking). Same lowercase dotted style as packages.
(Spaces and packages naming is a recommendation, not yet hard convention — open to revision.)
Interfaces mirror the module’s real interface — name them by its protocol
Section titled “Interfaces mirror the module’s real interface — name them by its protocol”An interface models the actual interface a module exposes, so its name should follow the semantics of the real protocol. For REST, use <action><Resource> — the REST action plus the resource name, like getOrder or createInvoice. For RPC, use verbs — the actual call names, like chargeCard or reserveSeat.
The guiding idea is not to invent abstract interface names but to reflect what the module truly offers, in the terms of its protocol. Casing still follows the conventions above: lowerCamelCase.
Use surfaces — group related interfaces
Section titled “Use surfaces — group related interfaces”Surfaces are good; use them, don’t ignore them. Group interfaces by surface when they form a coherent group. If an interface is unrelated to the rest, it doesn’t belong in the group — put it elsewhere, in its own surface or directly on the module. An interface that stands alone can live in the module itself rather than being forced into a surface.
Surfaces are interface domains. A surface groups interfaces into a coherent interface domain — a userInteraction surface holds the user-interaction interfaces. It’s the same domain-thinking that drives file and module organization, applied to interfaces, and surfaces can and should nest when the interface domain has sub-domains.
The grouping test is the same as for files: imagine writing all these interface bindings out. Bindings you’d happily keep in a single file belong in the same surface; bindings you’d rather split into separate files belong in different surfaces. It’s the same instinct as Organize files by domain — surfaces are just domains at the interface level.
Build reusable surfaces. It’s encouraged — the way we do with REST CRUD: a standard surface shape applied across modules instead of re-declaring the same interfaces each time.
Scope & linking
Section titled “Scope & linking”ArchLang is not a database or API modeling tool
Section titled “ArchLang is not a database or API modeling tool”Know what ArchLang is not for, and reach for the right tool. For database and table modeling, use proper SQL DDL — ArchLang offers ways to draw databases, tables, and connections between them, but it is not a table-modeling language and doesn’t try to be; those affordances are for informal sketches. For API modeling, use OpenAPI or the proper IDL for the protocol.
Drafting is fine — detail belongs elsewhere. You’re free to freeform: sketch specific API fields or contract ideas in an interface description, jot a few table columns. Just don’t mistake the sketch for the source of truth. When you need real detail, especially in a large company, link out to the proper spec instead of reproducing it. On an interface, a field can link to the OpenAPI spec stored elsewhere. On a database, link to your schema or data catalog — a catalog like DataHub or Amundsen, or a schema and migration tool like Atlas or Liquibase — the system that auto-gathers and structures your schemas.
Link out — make the architecture a links hub
Section titled “Link out — make the architecture a links hub”Always put external links. Internal architecture links are a given — that’s what the language is for. The point here is external links: connect each element to the real-world resource it represents, as many as you can.
Attach a link field wherever one exists. A service links to its repository. A database links to its console or login. Observability infra (Prometheus, Grafana, referenced via aspects) links to its admin panel or dashboard. Docs link to Confluence or wiki pages. Environments link to dev, staging, and prod.
The payoff is that the model becomes a links hub — one place that routes you to everything about the system. Links are useful, links simplify, and good architecture documentation lives through its links.
Linking is cheap because it’s per-element decoration. It fits the module-by-module pass: pick one module, focus entirely on decorating just that one — description, fields, links — then move on. No need to hold the whole system in your head. One element at a time, you steadily build out a rich, well-linked entry for each piece.
Formatting
Section titled “Formatting”Single spaces — no table-style alignment
Section titled “Single spaces — no table-style alignment”Separate tokens with a single space. Do not pad with multiple spaces to line things up into columns.
- ❌ Don’t align process steps or interfaces into a table (extra spaces so call sites or types line up vertically).
- ✅ One space between tokens:
<type> <action> <target>.
Column alignment looks tidy but rots: every rename re-breaks the columns, and it produces noisy, alignment-only diffs. Single-space formatting is stable and keeps diffs meaningful.
Dot notation vs curly braces — by value count
Section titled “Dot notation vs curly braces — by value count”There are two ways to write fields, aspects, and the like. Pick by how many values you have. A single value or one-liner reads well as dot notation, like aspect something.something. A couple of values (around two) can go either way — dot notation is fine, braces are fine, your call. Many values (around five or more) read better in curly braces; dot notation isn’t forbidden, but braces win at that size.
Inline braces for a one-liner are fine — keep them short. For a small number of pairs you can write aspect { first: x; second: y } on one line, but cap inline braces at two pairs, three at most. Beyond that, break onto multiple lines. Neither notation is ever mandatory — these are preferences, not rules.
Diffs & reviews
Section titled “Diffs & reviews”What to look for when reviewing
Section titled “What to look for when reviewing”In rough priority order:
- Domain separation — the top thing. Is the model split into the right domains? Does each piece sit in the domain it belongs to?
- Boundaries — do all things respect their boundaries? Watch for elements reaching across a boundary they shouldn’t.
- Naming — also important, even if lower priority. Renaming is trivial in ArchLang but expensive in real life, where the name maps to real systems, teams, and code. So treat names seriously now, while they’re cheap to change in the model — a bad name that ships becomes a costly real-world rename.
- Outstanding
TODOs (draft debt) — TODOs are unpromoted stubs: referenced-but-undefined modules, interfaces, or subprocesses. They’re fine mid-draft, but a mature model shouldn’t ship with them. Check the count, and consider gating “ready to propose or accept” on zero TODOs. Distinguish aTODOfrom an error (a contradiction) and a warning (discouraged but valid) — aTODOis missing detail, not wrong.
Build boundaries with policies (once policies land)
Section titled “Build boundaries with policies (once policies land)”When policies are implemented, don’t forget to enforce boundaries with policies — not just by convention. Boundaries that matter should be backed by explicit policy, so the model enforces the separation rather than relying on reviewers to catch every crossing.
Anti-patterns
Section titled “Anti-patterns”A quick index of “don’t”s, each pointing at the entry that explains it.
- Infrastructure as a routing waypoint —
A → broker → B. Model the logical call; attach infra as an aspect. → Model the business layer. - Do-nothing custom types — a behaviorless
serviceblock wrapping a plain module. → Plain modules and interfaces are fine by default. - Module-per-class or over-deep nesting — maps programming onto architecture, unmaintainable. → Pick the right granularity.
- Service-row-with-DB-cylinders — each service wired to its own database as a sibling box. Single-owner DBs nest inside the service. → Encapsulate single-owner resources.
- Nesting a cross-plane host — putting a DBMS or server inside a module instead of linking it by aspect. Deployment is a different plane. → Two axes: nesting is domain depth, aspects connect planes.
- Artificial single root
systemmodule — wrapping the whole world in one umbrella node. → Prefer nesting, but don’t force a single root module. - Modules in no process — reads as dead code; usually a forgotten process. → Model as many processes as possible.
- Files named by element type —
processes.arch,types.arch,views.arch. → Organize files by domain, not by element type. - Nesting spaces for technical layering — spaces are for ownership, not decomposition. → Default to a single root space.
use *from large or standard libraries — drags in the whole surface. → Prefer explicituse.- Meaningful IDs — embedding domain, type, or semantics; tempts you to mutate the ID later. → IDs must be meaningless and permanent.
- Mutating an ID — ever, for any reason. → IDs must be meaningless and permanent.
- Boundaries by convention only — enforce with policies once available. → Build boundaries with policies.
- Full DB schema or API spec inside ArchLang — it’s not a DDL or OpenAPI replacement; link out to the real spec. → ArchLang is not a database or API modeling tool.
- No external links — elements that don’t point at their real repo, console, dashboard, or docs. → Link out — make the architecture a links hub.