Skip to content

Repository files navigation

eslint-plugin-code-architecture

Portable ESLint rules that turn architectural decisions into fast, local feedback for humans and coding agents.

See architectural drift before review

The plugin makes important design decisions executable:

  • Eleven rules are framework-agnostic TypeScript checks that work equally well in backend services, libraries, CLIs, and frontend applications.
  • Unsafe casts and raw JSON.parse calls fail where they are written.
  • Oversized functions, positional parameter growth, and missing invariants get immediate feedback.
  • Cross-feature imports must follow declared dependency direction and public entry points.
  • Effect failures remain typed and visible instead of being silently erased.
  • Optional React and React Native rules keep components declarative, accessible, tokenized, and consumer-composable.

I use this plugin to keep coding agents fast. Small functions, explicit boundaries, runtime evidence, and direct imports reduce the context an agent must reconstruct before it can make a safe change. The same constraints make code easier to isolate and test for humans.

Every rule has a passing example in Rule reference and examples, with detailed invalid cases and options on its linked rule page.

The design draws from TigerStyle, The Vertical Codebase, Components take care of themselves, and Composition is all you need. See References and attribution for the policy sources.

It is ESM-only, supports ESLint flat config, and does not require type-aware linting.

Install

npm install --save-dev eslint eslint-plugin-code-architecture typescript typescript-eslint

Quick start

// eslint.config.js
import architecture from "eslint-plugin-code-architecture";
import tseslint from "typescript-eslint";

const integrations = [
  // Add only when Effect is installed and used:
  // ...architecture.configs.effect,
  // Add for compound components whose consumers should own layout:
  // ...architecture.configs.composition,
  // Add only to files that implement or consume strict LEGO object APIs:
  // ...architecture.configs.lego,
  // Add only when React is installed and used:
  // ...architecture.configs.react,
];

export default tseslint.config(
  ...architecture.configs.strict,
  ...integrations,
  {
    files: ["**/*.{ts,tsx}"],
    languageOptions: { parser: tseslint.parser },
  },
);

Presets are flat-config arrays and fall into two groups:

  • Library-agnostic: recommended, tigerstyle, and strict. The strict preset combines the other two.
  • Optional library and architecture integrations: effect, react, composition, and lego. These are deliberately excluded from strict; enable them only when the corresponding library and conventions are used.

Production patterns

These patterns are adapted from two production applications that enable the rules as errors. Both are private, so product-specific names and domain details are redacted. The examples progress from TypeScript, through frontend/backend libraries and JSX composition, to React, and finally React Native. The complete rule index follows the same order.

TypeScript: keep logic small enough to test in isolation

max-function-lines prevents a workflow from becoming a context-heavy mini-application. This redacted production helper has one decision, explicit preconditions and postconditions, and a focused test surface.

export function maximumPayloadBytes(kind: PayloadKind, mimeType: string): number {
  assert(mimeType.length > 0, "maximumPayloadBytes requires a MIME type");

  const result = kind === "thumbnail" ? LIMITS.thumbnail : LIMITS.original;

  assert(Number.isInteger(result), "payload limit must be a whole number");
  assert(result > 0, "payload limit must be positive");
  return result;
}

An agent can understand and test this function without loading upload orchestration, storage, or UI code. When logic approaches the configured line limit, extract a named decision with its own contract instead of disabling the rule.

TypeScript: replace confident casts with runtime evidence

no-unsafe-type-assertions stops an agent from silencing uncertainty with as or !. A production event boundary replaced a double cast with a discriminated runtime schema:

const streamEventSchema: z.ZodType<StreamEvent> = z.discriminatedUnion("type", [
  z.object({ type: z.literal("started"), id: idSchema }),
  z.object({ type: z.literal("delta"), text: z.string() }),
  z.object({ type: z.literal("completed"), result: resultSchema }),
]);

const event = streamEventSchema.parse(input);

Tests can now prove rejection behavior for malformed input. Agents see the accepted variants in executable code instead of guessing whether a cast was justified.

Frontend/backend library: validate JSON at the boundary

An Effect Schema boundary validates parsed JSON immediately. The same pattern works in browser clients, Node.js services, workers, and CLIs.

const StreamEvent = Schema.Union(
  Schema.Struct({ type: Schema.Literal("started"), id: Id }),
  Schema.Struct({ type: Schema.Literal("delta"), text: Schema.String }),
  Schema.Struct({ type: Schema.Literal("completed"), result: Result }),
);

const event = Schema.decodeUnknownSync(StreamEvent)(JSON.parse(message));

JSX component composition: let the consumer choose the parts

The owner exports an open root and independent parts. The consumer—not the root—decides whether the optional label exists and where each part appears.

const HeadingRoot = ({ children }: PropsWithChildren) => <View>{children}</View>;
const HeadingLabel = ({ children }: PropsWithChildren) => <Text>{children}</Text>;
const HeadingTitle = ({ children }: PropsWithChildren) => <Text>{children}</Text>;

export const Heading = {
  Root: HeadingRoot,
  Label: HeadingLabel,
  Title: HeadingTitle,
};

<Heading.Root>
  {label && <Heading.Label>{label}</Heading.Label>}
  <Heading.Title>{title}</Heading.Title>
</Heading.Root>;

React: render state and send events

The optional React rule keeps state-machine components on a declarative path: read the snapshot, pass events outward, and keep orchestration out of the render function.

function StatusScreen() {
  const [snapshot, send] = useMachine(statusMachine);
  return <StatusView status={snapshot.value} onEvent={send} />;
}

React Native: make an interactive primitive carry its contract

The React Native adoption rules add UI-specific guarantees. In this redacted button primitive, require-interactive-component-contract keeps accessibility and disabled behavior together, while the configured design-value rules require semantic tokens instead of raw colors or spacing.

function Action({ label, disabled, loading, onPress, children }: ActionProps) {
  assert(label.trim().length > 0, "Action requires an accessible label");
  const unavailable = disabled || loading;

  return (
    <Pressable
      accessibilityLabel={label}
      accessibilityRole="button"
      accessibilityState={{ disabled: unavailable, busy: loading }}
      disabled={unavailable}
      onPress={onPress}
      style={styles.root}
    >
      {loading ? <ActivityIndicator color={tokens.color.actionContent} /> : children}
    </Pressable>
  );
}

These are representative patterns, not additional conventions imposed by the plugin. Each rule page linked below defines the exact syntax the rule can and cannot prove.

Adopt incrementally in an existing codebase

The presets describe a target state and intentionally report every violation. On an established codebase, enable a small baseline first, repair its findings, and then promote additional rules one at a time. Library integrations remain opt-in throughout the rollout.

import architecture from "eslint-plugin-code-architecture";
import tseslint from "typescript-eslint";

export default tseslint.config({
  files: ["src/**/*.{ts,tsx}"],
  languageOptions: { parser: tseslint.parser },
  plugins: { "code-architecture": architecture },
  rules: {
    "code-architecture/no-unsafe-type-assertions": "error",
    "code-architecture/no-unvalidated-json-parse": "warn",
  },
});

Once warnings are resolved, change them to errors and add the next rule. Apply effect, react, or composition only to files that use the corresponding library or architecture. Project-specific rules such as enforce-module-boundaries, centralize-domain-literals, and no-raw-design-values still require explicit consumer configuration.

Using Biome and Oxlint alongside the plugin

Biome and Oxlint can handle formatting and broad, high-speed linting while ESLint runs only the architectural rules that remain specific to this plugin. Teams can use either tool or both; this example uses both:

npm install --save-dev --save-exact @biomejs/biome
npm install --save-dev oxlint eslint eslint-plugin-code-architecture typescript typescript-eslint

Create biome.json using the Biome configuration guide. Keep library domains opt-in as well; for example, Biome's React domain should only be enabled in a React project:

{
  "formatter": { "enabled": true },
  "linter": {
    "enabled": true,
    "domains": { "project": "recommended" }
  }
}

Oxlint works without configuration. If a committed config is preferred, initialize one with npx oxlint --init; see the official Oxlint configuration documentation.

{
  "$schema": "./node_modules/oxlint/configuration_schema.json"
}

Use separate commands so each tool has a clear responsibility:

{
  "scripts": {
    "lint": "npm run lint:biome && npm run lint:oxlint && npm run lint:architecture",
    "lint:biome": "biome check .",
    "lint:oxlint": "oxlint .",
    "lint:architecture": "eslint .",
    "format": "biome check --write ."
  }
}

Oxlint also has an alpha JavaScript-plugin compatibility layer. This example intentionally keeps eslint-plugin-code-architecture on ESLint's stable plugin API and follows Oxlint's documented incremental migration approach: run fast general checks first, then ESLint for custom rules.

Project-specific rules

These rules need the consuming application's module names, vocabulary, or design-system contract. They are not enabled by a preset.

TypeScript: architecture boundaries

Verticals are deliberately configured by the consuming application because domain names and dependency direction are project-specific:

import architecture from "eslint-plugin-code-architecture";

export default [
  {
    files: ["src/**/*.{ts,tsx}"],
    plugins: { architecture },
    rules: {
      "architecture/enforce-module-boundaries": [
        "error",
        {
          modules: [
            {
              name: "checkout",
              pattern: "src/checkout/**",
              allow: ["catalog"],
            },
            {
              name: "catalog",
              pattern: "src/catalog/**",
              public: ["**/*.api.*", "**/*.service.*"],
            },
          ],
          allowPrivateImportsFrom: ["src/runtime/**"],
        },
      ],
    },
  },
];

This supports relative imports and configurable aliases. Cross-vertical imports must follow each source vertical's allow list and each target vertical's public patterns. Composition roots can be granted explicit private access.

TypeScript: domain vocabulary

ESLint visits files independently, so a reliable “literal appears in two files” rule cannot aggregate whole-program state. centralize-domain-literals instead requires the project to declare its fixed vocabulary and approved constants files. This deterministically enforces the stronger rule from the first use.

"architecture/centralize-domain-literals": [
  "error",
  {
    constantsFiles: ["src/constants.ts"],
    literals: [
      { value: "completed", replacement: "JOB_STATUS.COMPLETED" },
      { value: "ollama", replacement: "AI_PROVIDERS.OLLAMA" },
    ],
  },
]

React: design tokens, including React Native

no-raw-design-values prohibits explicitly configured string or numeric values only when they appear in configured object properties. Consumers provide the semantic meaning: which values and properties belong together, their approved token replacements, token files, and narrow exceptions.

"architecture/no-raw-design-values": [
  "error",
  {
    allowedFiles: ["src/ui/tokens/**"],
    values: [
      {
        properties: ["color", "backgroundColor"],
        replacement: "tokens.color.surface",
        value: "#edf0eb",
      },
      {
        properties: ["gap", "padding"],
        replacement: "tokens.space.md",
        value: 16,
      },
    ],
    exceptions: [
      {
        files: ["src/charts/**"],
        properties: ["color"],
        values: ["#edf0eb"],
      },
    ],
  },
]

The rule is deliberately excluded from every preset. It does not assume React, React Native, CSS-in-JS, a token API, or that an arbitrary repeated number is a design value. See no-raw-design-values for its syntax-only limits.

Design-system adoption rules are also opt-in. Activate a rule only after the matching primitive, token family, interaction contract, dismissal pattern, or component variants exist and their intended consumers have migrated. Enabling them earlier would turn architectural feedback into suppressions rather than adoption.

Rule reference and examples

Every rule has a compact passing example here, ordered from TypeScript to libraries, JSX composition, React, and React Native. Follow its link for failing examples, options, scope, and static-analysis limits. “Configure” means the rule needs project-specific vocabulary or component names; optional presets are never included by strict.

1. TypeScript

These rules have no UI or framework dependency. Use them in backend services, libraries, CLIs, workers, or frontend TypeScript.

Rule Immediate benefit Passing shape Preset
centralize-domain-literals Fixed vocabulary has one owner if (job.status === JOB_STATUS.COMPLETED) {} Configure
enforce-module-boundaries Features use declared public edges import { findProduct } from "../catalog/product.api.js"; Configure
imports-first Dependencies stay visible import { parse } from "./parse.js"; before executable code recommended
max-function-lines Logic stays reviewable function total(items) { return items.reduce(sum, 0); } recommended, tigerstyle
max-function-parameters APIs resist positional growth function search({ query, limit, cursor }) {} recommended, tigerstyle
no-barrel-files Dependency edges stay direct Define export function charge() {} in charge.js recommended
no-barrel-imports Imports reveal their owner import { charge } from "./charge.js"; recommended, effect
no-unasserted-return Returned call results carry evidence const user = await loadUser(); assert(user.id); return user; Configure
no-unsafe-type-assertions Unknown data cannot bypass checks Schema.decodeUnknownSync(User)(input) recommended
no-unvalidated-json-parse Parsed JSON is validated immediately Schema.decodeUnknownSync(Config)(JSON.parse(text)) recommended
require-assertions Function invariants become executable assert(result.length <= input.length); return result; tigerstyle

2. Frontend and backend libraries

Library integrations remain opt-in and can apply on either side of the network.

Rule Immediate benefit Passing shape Preset
effect-error-handling Effect failures remain typed and visible program.pipe(Effect.catchTag("NotFound", recover)) effect

3. JSX component composition

These opt-in rules keep compound layout decisions with consumers. They analyze JSX structure without requiring React-specific hooks or state APIs.

Rule Immediate benefit Passing shape Preset
prefer-composition-over-configuration Callers assemble collections and optional parts <List.Root>{items.map((item) => <List.Item key={item.id} />)}</List.Root> composition
require-composable-root-children Every root leaves its hierarchy open function ListRoot({ children }) { return <ListContext.Provider>{children}</ListContext.Provider>; } composition
no-root-owned-compound-parts Roots provide infrastructure, not fixed parts function ListRoot({ children }) { return <View>{children}</View>; } composition
require-compound-component-api Compound APIs expose a boundary and reusable parts export const Counter = { Provider, Display, Increment }; lego
require-consumer-owned-compound-usage Consumers visibly choose compound parts <Counter.Provider><Counter.Display /><Counter.Increment /></Counter.Provider> lego

How the composition preset combines these rules

The composition preset provides portable architectural guardrails: a root may coordinate state and infrastructure, but the consumer owns the child hierarchy. It catches statically visible prop-driven hierarchy assembly, requires every top-level boundary return to reference children, and prevents a boundary from rendering its own public parts.

Invalid:

function Accordion({ items, showFooter, renderFooter }) {
  return (
    <section>
      {items.map((item) => <AccordionItem item={item} />)}
      {showFooter && renderFooter()}
    </section>
  );
}

Valid:

<Accordion.Root open={open} setOpen={setOpen}>
  {items.map((item) => (
    <Accordion.Item key={item.id}>
      <Accordion.Trigger>{item.title}</Accordion.Trigger>
      <Accordion.Content>{item.content}</Accordion.Content>
    </Accordion.Item>
  ))}
</Accordion.Root>

Passing composition does not prove a complete LEGO architecture. The preset deliberately does not mandate dot-notation object exports, barrel files, React useState, or a state/actions/meta context shape. Module namespace exports are equally composable, and context organization is a separate convention rather than proof that consumers control structure.

How the lego preset adds a public compound API

The opt-in lego preset combines composition with positive, convention-oriented checks. Apply it only to files that define or consume compound APIs; ordinary screens and components are not required to become compounds.

const CounterProvider = ({ children }) => (
  <CounterContext.Provider value={actor}>{children}</CounterContext.Provider>
);
const CounterDisplay = () => <output />;
const CounterIncrement = () => <button />;

export const Counter = {
  Provider: CounterProvider,
  Display: CounterDisplay,
  Increment: CounterIncrement,
};

<Counter.Provider>
  <Counter.Display />
  <Counter.Increment />
</Counter.Provider>

By default, an identified compound object must be exported, expose Provider or Root, expose at least two additional component-valued parts, and avoid duplicate bindings. Imported or same-file compound boundaries must be open and contain a consumer-selected part from the same namespace. Configure boundaryMembers, minimumParts, compoundNamePattern, or headlessCompounds for other conventions and intentional actor/store-backed headless boundaries.

The rules use deterministic same-file syntax analysis. They can validate bindings declared or imported in the current file, but they do not resolve re-export graphs, prove that a component consumes a particular context across files, or prove shared state semantics. No shared-state rule is shipped because naming a hook is not reliable evidence that state is shared. Actor and store implementations are supported without requiring React local state; teams may scope the preset and configure their boundary names without adopting state/actions/meta.

4. React

React integration is opt-in. These rules keep components declarative and enforce an adopted design system. The design-system rules support both web and native component contracts.

Rule Immediate benefit Passing shape Preset
declarative-components Components render state and send events const [snapshot, send] = useMachine(machine); return <Button onPress={send}>{snapshot.value}</Button>; react
no-design-identity-overrides Consumers cannot restyle component identity <Button style={{ marginTop: 12 }} /> Configure
no-raw-design-properties New raw design literals fail immediately { color: theme.color.danger, gap: theme.space.md } Configure
no-raw-design-values Known raw values point to their token <Spinner color={tokens.color.surface} /> Configure
prefer-design-system-components Screens reuse adopted primitives <Button>Save</Button> instead of a configured platform primitive Configure

5. React Native interactions

These interaction rules are listed last because the examples use React Native modal and pressable conventions. Both rules can also be configured for web React elements and attributes.

Rule Immediate benefit Passing shape Preset
require-dismissible-modal-backdrop Transparent modals have both close paths <Modal transparent onRequestClose={close}><Pressable onPress={close} /></Modal> Configure
require-interactive-component-contract Shared controls expose accessibility, disabled state, feedback, and content <Pressable accessibilityRole="button" accessibilityState={{ disabled }} disabled={disabled} style={({ pressed }) => pressed && styles.pressed}>{children}</Pressable> Configure

Publishing

See PUBLISHING.md for first-release and automated publishing instructions.

Development

bun install
bun run check
bun run test:coverage
npm pack --dry-run

Every rule change should start with one observable failing test, followed by the smallest implementation and a refactor pass.

npm run smoke:package additionally packs the publishable tarball, installs it into a clean temporary project, and runs ESLint through the package's public export.

License

MIT

About

Portable ESLint rules for vertical architecture, TigerStyle, Effect, and runtime safety

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages