TWISTEdBRACKETS

SOLID Principles Matter More With AI Coding Agents, Not Less

Published

25 July 2026

Listen to this post using the player at the bottom of the page.

An agent will happily write you a thousand lines before lunch. It will not tell you that three hundred of them belong in a different file, that the interface it just extended breaks a promise made two modules away, or that the "quick fix" it bolted onto a function has quietly coupled your billing logic to your email templates. It writes code that runs. Whether it holds together is a different question, and it is one the model has no particular incentive to ask.

That is where SOLID comes back in. Five design principles from Robert C. Martin, one letter each: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. Most of us met them in a university OOP course, filed them under "Java things," and moved on with our JavaScript careers. They never stopped applying. They just got less urgent when a careful senior engineer was the one typing every line.

Hand the keyboard to an agent and the maths changes. Code volume goes up. Review time does not scale with it. The person catching a bad abstraction can no longer read every diff line by line, so the question stops being "can I spot this" and becomes "does the structure make this kind of mistake hard to write in the first place." SOLID is a decent answer to that question. Not because it is fashionable, but because each rule maps onto a specific way agents go wrong.

Five letters, one shape

LetterPrincipleOne-line version
SSingle ResponsibilityOne reason to change per module
OOpen/ClosedExtend behaviour without editing what already works
LLiskov SubstitutionA subtype must honour its parent's contract
IInterface SegregationDepend only on what you actually use
DDependency InversionDepend on abstractions, not concrete implementations

None of these are exotic. What is worth examining is why each one lands differently once an agent, not a person, is the one deciding where a new line of code goes.

S: Single Responsibility Principle

What it is: a module, function, or component should have one reason to change. If a class handles validation, persistence, and notifications, three unrelated business decisions can each force an edit to the same file.

Why it matters for agents: an agent working task by task tends to edit whatever file already contains the closest-looking logic, because that is the path of least resistance through the context it has loaded. If your order handler already validates, saves, and emails, the next feature request ("also send a Slack alert") gets bolted onto that same function instead of triggering the more obviously correct move of adding a new, separate step. Every unrelated concern packed into one file is a plausible landing spot for the next unrelated change.

javascript
// Before: one route handler owns validation, persistence, and emailexport async function POST(request) {  const body = await request.json();
  if (!body.email || !body.items?.length) {    return Response.json({ error: "Invalid order" }, { status: 400 });  }
  const order = await db.orders.create({    data: { email: body.email, items: body.items, total: calculateTotal(body.items) },  });
  await sendEmail({    to: body.email,    subject: "Order confirmed",    body: `Thanks for your order #${order.id}`,  });
  return Response.json(order);}
javascript
// After: each concern is a function with one jobfunction validateOrder(body) {  if (!body.email || !body.items?.length) {    throw new ValidationError("Invalid order");  }}
async function createOrder(body) {  return db.orders.create({    data: { email: body.email, items: body.items, total: calculateTotal(body.items) },  });}
async function sendOrderConfirmation(order) {  await sendEmail({    to: order.email,    subject: "Order confirmed",    body: `Thanks for your order #${order.id}`,  });}
export async function POST(request) {  const body = await request.json();  validateOrder(body);  const order = await createOrder(body);  await sendOrderConfirmation(order);  return Response.json(order);}

The second version is not shorter. It is a better place to land the next change. Ask for SMS notifications and there is now an obvious file to add sendOrderSms to, instead of a growing route handler with four unrelated jobs.

O: Open/Closed Principle

What it is: modules should be open for extension but closed for modification. Adding a new case should mean adding new code, not editing code that already works and is already tested.

Why it matters for agents: an agent asked to "add a new coupon type" will, left to its own devices, open the existing discount function and add another else if branch. That branch sits next to logic covered by tests it did not write and may not fully understand. Every edit to that function is a chance to break a coupon type nobody asked it to touch.

javascript
// Before: every new coupon type means editing this functionfunction applyDiscount(order, coupon) {  if (coupon.type === "percentage") {    return order.total * (1 - coupon.value / 100);  } else if (coupon.type === "fixed") {    return Math.max(0, order.total - coupon.value);  } else if (coupon.type === "freeShipping") {    return order.total - order.shippingCost;  }  // A new coupon type means another else-if, right here  return order.total;}
javascript
// After: new coupon types register themselves, existing ones stay untouchedconst discountStrategies = {  percentage: (order, coupon) => order.total * (1 - coupon.value / 100),  fixed: (order, coupon) => Math.max(0, order.total - coupon.value),  freeShipping: (order, coupon) => order.total - order.shippingCost,};
function applyDiscount(order, coupon) {  const strategy = discountStrategies[coupon.type];  return strategy ? strategy(order, coupon) : order.total;}
// Adding "buyOneGetOne" is a new entry, not an edit to the three above itdiscountStrategies.buyOneGetOne = (order, coupon) => calculateBogoDiscount(order, coupon);

Now the instruction "add a coupon type" has an unambiguous shape: add a key to the map. There is no longer a tempting third else if for the agent to slot in next to two it has never read carefully.

L: Liskov Substitution Principle

What it is: anywhere the parent type is expected, a subtype must work as a drop-in replacement. If code that handles PaymentMethod breaks when handed a specific subclass, that subclass has violated the contract, even if it technically implements every method.

Why it matters for agents: an agent extending a base class or implementing a shared interface tends to satisfy the method signatures without checking the caller's assumptions about behaviour. It compiles, the shape matches, and it still breaks the code calling it, because the contract was never written down anywhere the agent could read it.

javascript
// Before: GiftCardPayment "implements" refund(), but breaks the contractclass PaymentMethod {  async charge(amount) { /* ... */ }  async refund(amount) { /* ... */ }}
class CreditCardPayment extends PaymentMethod {  async refund(amount) {    return stripe.refunds.create({ amount });  }}
class GiftCardPayment extends PaymentMethod {  async refund(amount) {    // A new subtype an agent added later, following the same interface    throw new Error("Gift cards cannot be refunded");  }}
// Anywhere this loop runs, GiftCardPayment silently breaks itfor (const payment of order.payments) {  await payment.refund(order.total);}
javascript
// After: the contract is explicit, so an incompatible subtype can't hideclass PaymentMethod {  async charge(amount) { /* ... */ }  get refundable() {    return true;  }  async refund(amount) {    if (!this.refundable) {      throw new NonRefundableError(this.constructor.name);    }  }}
class GiftCardPayment extends PaymentMethod {  get refundable() {    return false;  }}
for (const payment of order.payments) {  if (payment.refundable) {    await payment.refund(order.total);  } else {    await flagForManualRefund(payment, order);  }}

The fix is not really about gift cards. It is about making the contract something callers can check, instead of something only a human who remembers the original design would notice was broken.

I: Interface Segregation Principle

What it is: clients should not be forced to depend on methods they do not use. A fat interface with fifteen methods, where every implementer needs three of them, creates fourteen ways for an unrelated change to ripple through code that never asked for it.

Why it matters for agents: a broad shared type is an easy target for an agent to extend when it needs "just one more field." A useAccount() hook that returns profile data, billing state, notification preferences, and admin flags gets consumed everywhere, so every component that only wanted the user's name is now re-rendering on unrelated billing updates, and every future change to the hook risks all of them at once.

javascript
// Before: one hook, everything in it, every consumer pays for all of itfunction useAccount() {  const profile = useProfileQuery();  const billing = useBillingQuery();  const notifications = useNotificationPrefsQuery();  const adminFlags = useAdminFlagsQuery();
  return { profile, billing, notifications, adminFlags };}
// A component that only needs a name still re-renders on billing changesfunction Greeting() {  const { profile } = useAccount();  return <p>Welcome back, {profile.data?.name}</p>;}
javascript
// After: narrow hooks, each consumer depends only on what it usesfunction useProfile() {  return useProfileQuery();}
function useBilling() {  return useBillingQuery();}
function useNotificationPrefs() {  return useNotificationPrefsQuery();}
function Greeting() {  const profile = useProfile();  return <p>Welcome back, {profile.data?.name}</p>;}

The next feature request, "show billing status in the sidebar," now naturally reaches for useBilling() instead of pulling in the same everything-hook and quietly widening what Greeting depends on.

D: Dependency Inversion Principle

What it is: high-level modules should depend on abstractions, not on concrete implementations. Business logic should describe what it needs, not which specific vendor or library provides it.

Why it matters for agents: an agent reaching for "send an email" or "charge a card" will usually import the concrete SDK it already sees used nearby and call it directly, because that is the fastest path to green. That is fine right up until you switch providers, run the code in a test, or need a second agent-built feature to reuse the same logic against a mock. Direct SDK calls buried in business logic are difficult to test and expensive to change later, exactly the two things you want cheap when an agent is iterating quickly.

javascript
// Before: business logic imports and calls Stripe directlyimport Stripe from "stripe";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function chargeOrder(order) {  return stripe.charges.create({    amount: order.total,    currency: "usd",    source: order.paymentToken,  });}
javascript
// After: business logic depends on an abstraction, not a vendorfunction createPaymentGateway({ charge }) {  return { charge };}
const stripeGateway = createPaymentGateway({  charge: (order) =>    stripe.charges.create({      amount: order.total,      currency: "usd",      source: order.paymentToken,    }),});
async function chargeOrder(order, gateway = stripeGateway) {  return gateway.charge(order);}
// Tests, or a future agent swapping providers, pass a fake gateway insteadtest("chargeOrder records the transaction", async () => {  const fakeGateway = createPaymentGateway({ charge: async () => ({ id: "test_123" }) });  const result = await chargeOrder(testOrder, fakeGateway);  assert.equal(result.id, "test_123");});

Nothing about chargeOrder changed its job. What changed is that swapping Stripe for a different processor, or testing the function without hitting a real API, no longer means rewriting the function itself.

Why this is a harness concern, not just a style preference

Put the five together and a pattern shows up: every one of them is really about limiting blast radius. SRP limits which changes land in the same file. OCP limits which working code an edit can touch. LSP limits which callers a new subtype can silently break. ISP limits which unrelated changes ripple through a shared dependency. DIP limits how deep a vendor choice reaches into logic that should not care.

That is precisely the property you want when the thing making changes cannot hold the whole codebase in its head at once, whether that is a new hire on their first week or an agent working from a context window. SOLID does not make an agent smarter. It makes the codebase harder to damage by accident, which is a more reliable thing to depend on than an agent's judgement improving on its own.

That is now a proper solid skill: Architect mode for shaping non-trivial application code, and Critic mode for reviewing a branch or module against the same five rules before it ships — the same job ui-ux-best-practices does for generic UI patterns and conformance-check does for spec drift in the development harness.

Install it with dot-skills:

sh
npx dot-skills add levi-putna/twistedbrackets.com/solid

For modes, the example report shape, and the full install notes, see the solid skill page.

Then ask for either mode:

Architect

Design the checkout feature the SOLID way: split responsibilities, keep extension points for new coupon types, and invert the payment and email dependencies so tests can pass fakes.

Critic

Review the changes in this branch against SOLID: flag anything that violates single responsibility, open/closed, Liskov substitution, interface segregation, or dependency inversion, and explain the specific risk each violation creates.

Ask for the critic pass on every non-trivial diff and most of what this post covers happens automatically. The five letters were never really about Java. They were always about making change safe when the person, or the agent, making it cannot see the whole picture at once.