This is the most common objection to Use Case Pattern; almost everyone I show the methodology to for the first time raises it. In various wordings:

— Why would I need your pattern, Claude will write the code for me anyway. — These days you can just describe what you need — and it gets generated. — Methodologies are for the era when AI couldn't do it. — Why do you all cling to layered architecture, just give AI the task — it'll handle it.

I agree with this. Partially. And it's precisely because I agree partially that I'm building Use Case Pattern.

This article is a long answer to the objection. The goal: to convince you not that AI is bad (it's excellent), but that a methodology in the age of AI becomes not unnecessary, but more valuable. The smarter AI gets, the more it depends on which rules you give it.


Where AI without a methodology really is enough

Let me start with an admission. For a whole class of tasks, AI without any methodology does the job brilliantly:

  • Prototype, trial implementation, hypothesis check. One developer, one service, the goal — in two weeks verify whether the market needs this. Here a methodology only slows you down. Grab Claude, describe the task, get the code. In three months you'll either rewrite it from scratch or throw it away. Any methodological cost is money down the drain.
  • Scripts, utilities, one-off automation. A single Python file that assembles a report once a week. No team will maintain it. No one will review it. If it works — it works.
  • Refactoring with a clear goal. "Extract this method into a separate class", "rewrite the switch as pattern matching" — Claude does this excellently in any context. What you need is tests and common sense, not a methodology.
  • Isolated learning tasks. "Show me how a Saga works with a simple example." Here a methodology even gets in the way — a simplified example should be simplified.
  • A personal pet project. One person, one code style (even if it changes every month), no team, no long-term maintenance. Do whatever is convenient.

If your context is one of these, close this article. You don't need UCP. The rest of this conversation is about a different context.


Where AI without a methodology falls apart

The context in which a methodology is needed is a team, a product, time. Any two of the three are already a reason to think about it.

In the backend cluster I lead, the team is 4 feature teams plus a platform team, 20+ engineers. The product is a government IT system with a planned lifespan of 5+ years. The time horizon is years ahead, not a sprint. And in that context I've observed five scenarios in which AI without a methodology falls apart time and again.

1. Inconsistency between sessions

Give Claude the same task three times: "write an order-processing service with a REST API for create, pay, cancel." In three separate chats, with no shared context whatsoever.

You'll get three different solutions. All three work. All three are typed, with tests, with error handling. But:

  • In the first — three separate controllers: OrderController, PaymentController, CancelController.
  • In the second — a single OrderController with three methods: POST /orders, POST /orders/{id}/payment, POST /orders/{id}/cancel.
  • In the third — an OrderController with methods plus an OrderCommandHandler for commands.

Each solution on its own is fine. Together — three different API-design styles in one codebase. In a month you have 30 services, each one slightly different. In a year a new developer on the team spends their first week just "figuring out how we do things here."

Claude's decision depends on what was more common in its training data at the moment of the specific request. Without explicit rules from outside, it won't pick your style — it'll pick the average of all the developers in the world.

2. Inconsistency between developers

The same task, but it's solved by developer A, B, C — each in their own Claude session.

A — worked with Spring Data before, will ask Claude to use Spring Data JPA. B — came from Kotlin/Ktor, will ask Claude to do it with plain JDBC + DTOs. C — recently read Vaughn Vernon, will ask Claude to do it "the DDD way."

All three will get working code from Claude. All three will consider it "correct" within their own context. And after merging the PRs into main you have three services in one repository, written in three incompatible styles, and none of the three did anything "wrong."

This isn't a Claude bug. It's a consequence of Claude having no source of truth about your style other than the specific developer's prompt.

3. Inconsistency over time

You build the service in January. Claude generates beautiful code. A year later — you add a new feature. Again you ask Claude.

Over the year Claude has been updated to a new version. The training data changed. It understands "best practices" differently. It'll generate code that doesn't resemble your January code. In review someone notes: "we don't usually do it this way." But usually how? Nobody saved the Claude prompt from January. The agreements live in the heads of developers, many of whom have already left.

You get drift — every new piece of code is in a slightly different style than what came before. In 3–5 years the code looks like an archaeological dig: "this was written in 2025, you can tell by the naming; and this in 2027, a different school already."

4. A complex domain and architectural trade-offs

Claude doesn't make architectural decisions. It applies templates.

Ask it: "I have an order that must be paid through an external gateway, then shipped via a courier service, and if shipping failed — refund the money. How do I do this?"

Claude will write one of the variants:

  • Direct calls (oh well)
  • Synchronous orchestration in a single transaction
  • Saga with compensations
  • Event-driven via Outbox
  • 2PC via Atomikos (if it feels like it)

It'll write all the variants competently. But the choice between them is an architectural decision that depends on a dozen factors: the SLA, how much money you're willing to lose on failure, which neighboring teams exist, whether you can ask the customer to wait 5 seconds, how critical idempotency is, and so on.

Without your methodology, Claude will pick the "most popular in training data" solution for a task like this. It'll be competent, but not necessarily right for your context.

With a methodology — Claude has instructions: "in our project the maturity level is 3 (DDD + Hexagonal), for distributed processes we use Saga + Outbox, idempotency is key, we avoid synchronous distributed transactions." Then Claude picks not the average, but yours.

5. Quality regression by default

By default Claude does what looks correct, but not always what is correct.

A few typical defaults of a "bare" Claude:

  • On an error it returns null or an empty result without logging
  • In a catch block it writes // log error and continue without explicit handling
  • It doesn't implement idempotency unless you explicitly ask for it
  • It doesn't add metrics (nobody mentioned metrics, after all)
  • Timeouts on external calls are the defaults (that is, infinite)
  • It returns DTOs outward as-is, even when there's a domain object inside

Each of these defaults technically works. But in production, six months later, you catch: silent errors nobody ever saw, hanging connections to a downed external service, duplicate orders — because someone clicked "buy" twice.

With methodology rules Claude's behavior changes: "don't return null, return a typed error", "error handling is mandatory, swallowing is forbidden", "all external calls with a timeout from config", "DTO != domain". These rules must be checkable — otherwise nobody follows them.


Demonstration: the same brief, two passes

To make this more than words, let's work through a concrete example.

Brief (identical for both passes): "Build a service that accepts a command to pay for an order. It must call the payment gateway, get the response, save the status. If the gateway doesn't respond — retry. If it declines — leave the order in pending."

Pass 1: Claude without a methodology

Give this prompt to Claude in a clean session. You'll get roughly this code:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    @Autowired
    private PaymentService paymentService;

    @PostMapping("/{orderId}/pay")
    public ResponseEntity<String> pay(@PathVariable String orderId) {
        try {
            paymentService.processPayment(orderId);
            return ResponseEntity.ok("Payment processed");
        } catch (Exception e) {
            return ResponseEntity.status(500).body("Payment failed: " + e.getMessage());
        }
    }
}

@Service
public class PaymentService {

    @Autowired
    private PaymentGatewayClient gateway;

    @Autowired
    private OrderRepository orderRepository;

    public void processPayment(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new RuntimeException("Order not found"));

        for (int i = 0; i < 3; i++) {
            try {
                PaymentResult result = gateway.charge(order.getAmount());
                if (result.isSuccess()) {
                    order.setStatus("PAID");
                } else {
                    order.setStatus("PENDING");
                }
                orderRepository.save(order);
                return;
            } catch (Exception e) {
                if (i == 2) throw new RuntimeException("Gateway unavailable", e);
                try { Thread.sleep(1000); } catch (InterruptedException ie) {}
            }
        }
    }
}
// handler/payment_handler.go
package handler

import (
    "net/http"

    "github.com/go-chi/chi/v5"
)

func (h *PaymentHandler) Pay(w http.ResponseWriter, r *http.Request) {
    orderId := chi.URLParam(r, "orderId")
    if err := h.service.ProcessPayment(r.Context(), orderId); err != nil {
        http.Error(w, "Payment failed: "+err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("Payment processed"))
}

// service/payment_service.go
package service

import (
    "context"
    "fmt"
    "time"
)

func (s *PaymentService) ProcessPayment(ctx context.Context, orderId string) error {
    order, err := s.orders.FindByID(ctx, orderId)
    if err != nil {
        return fmt.Errorf("order not found: %w", err)
    }

    var lastErr error
    for i := 0; i < 3; i++ {
        result, err := s.gateway.Charge(ctx, order.Amount)
        if err != nil {
            lastErr = err
            time.Sleep(time.Second)
            continue
        }
        if result.Success {
            order.Status = "PAID"
        } else {
            order.Status = "PENDING"
        }
        return s.orders.Save(ctx, order)
    }
    return fmt.Errorf("gateway unavailable: %w", lastErr)
}
// controller/payment.controller.ts
import { Controller, Post, Param, HttpException } from '@nestjs/common';

@Controller('payments')
export class PaymentController {
    constructor(private readonly paymentService: PaymentService) {}

    @Post(':orderId/pay')
    async pay(@Param('orderId') orderId: string): Promise<string> {
        try {
            await this.paymentService.processPayment(orderId);
            return 'Payment processed';
        } catch (e) {
            throw new HttpException('Payment failed: ' + e.message, 500);
        }
    }
}

// service/payment.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class PaymentService {
    constructor(
        private readonly gateway: PaymentGatewayClient,
        private readonly orders: OrderRepository,
    ) {}

    async processPayment(orderId: string): Promise<void> {
        const order = await this.orders.findById(orderId);
        if (!order) throw new Error('Order not found');

        for (let i = 0; i < 3; i++) {
            try {
                const result = await this.gateway.charge(order.amount);
                order.status = result.success ? 'PAID' : 'PENDING';
                await this.orders.save(order);
                return;
            } catch (e) {
                if (i === 2) throw new Error('Gateway unavailable: ' + e.message);
                await new Promise(r => setTimeout(r, 1000));
            }
        }
    }
}
# router/payment_router.py
import asyncio
from fastapi import APIRouter, Depends, HTTPException

router = APIRouter(prefix="/api/payments")

@router.post("/{order_id}/pay")
async def pay(order_id: str, service: PaymentService = Depends()):
    try:
        await service.process_payment(order_id)
        return {"message": "Payment processed"}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Payment failed: {e}")

# service/payment_service.py
class PaymentService:
    def __init__(self, gateway: PaymentGatewayClient, orders: OrderRepository):
        self.gateway = gateway
        self.orders = orders

    async def process_payment(self, order_id: str) -> None:
        order = await self.orders.find_by_id(order_id)
        if not order:
            raise ValueError("Order not found")

        last_err = None
        for i in range(3):
            try:
                result = await self.gateway.charge(order.amount)
                order.status = "PAID" if result.success else "PENDING"
                await self.orders.save(order)
                return
            except Exception as e:
                last_err = e
                await asyncio.sleep(1)
        raise RuntimeError(f"Gateway unavailable: {last_err}")

This is working code. Tests can be written for it, in production it'll spin just fine. But it has plenty of problems for a production system:

  • A raw string identifier instead of a typed OrderId
  • A base exception with no hierarchy — all errors are the same
  • String status = "PAID" — a magic string, not an enum
  • No idempotency: a repeated POST leads to a repeated charge
  • Business logic in the Service, not in the domain
  • Hand-rolled retry logic instead of a library mechanism
  • The payment gateway with no circuit breaker and no timeout
  • The response — a bare string with no typed DTO

Each of these remarks is a code-review item. On a team of 20 people, PRs like this eat up more time than the code itself.

Pass 2: Claude with a methodology

Now the same thing, but Claude knows: "we use Use Case Pattern, maturity level 3 (DDD + Hexagonal), a REST API style guide, all external calls go through a resilience mechanism (retry + circuit breaker + timeout), idempotency by Idempotency-Key is mandatory, domain logic lives in the aggregate."

This knowledge is loaded via Claude Code skills (ucp-pattern-design, ucp-api-design, ucp-java-style-review). No need to repeat it in every prompt — the skills apply automatically.

The same brief will yield roughly:

// adapter/in/rest/OrderController.java
@RestController
@RequestMapping("/v1/orders")
@RequiredArgsConstructor
@InboundAdapter("REST controller for order commands")
class OrderController {

    private final UseCaseDispatcher dispatcher;

    @PostMapping("/{orderId}/pay")
    PayOrderResponse pay(@PathVariable OrderId orderId,
                         @RequestHeader("Idempotency-Key") IdempotencyKey idempotencyKey,
                         @AuthenticationPrincipal Buyer buyer) {
        return dispatcher.dispatch(new PayOrderCommand(orderId, buyer.id(), idempotencyKey));
    }
}

// app/usecase/PayOrderCommand.java
record PayOrderCommand(OrderId orderId, BuyerId buyerId, IdempotencyKey idempotencyKey)
    implements UseCaseCommand<PayOrderResponse> {}

record PayOrderResponse(OrderId orderId, OrderStatus status, PaymentReference reference) {}

// app/usecase/PayOrderHandler.java
@InboundPort
@RequiredArgsConstructor
class PayOrderHandler implements UseCaseHandler<PayOrderCommand, PayOrderResponse> {

    private final OrderRepository orders;
    private final PaymentGateway gateway;
    private final IdempotencyStore idempotency;
    private final DomainEventPublisher events;

    @Override
    @Transactional
    public PayOrderResponse handle(PayOrderCommand cmd) {
        return idempotency.executeOnce(cmd.idempotencyKey(), () -> {
            Order order = orders.findById(cmd.orderId())
                .orElseThrow(() -> new OrderNotFoundException(cmd.orderId()));

            PaymentReference ref = gateway.charge(order.totalAmount(), cmd.idempotencyKey());
            order.markPaid(ref);
            orders.save(order);
            events.publish(order.pullEvents());

            return new PayOrderResponse(order.id(), order.status(), ref);
        });
    }
}

// adapter/out/payment/PaymentGatewayAdapter.java
@OutboundAdapter("HTTP client to payment gateway")
@RequiredArgsConstructor
class PaymentGatewayAdapter implements PaymentGateway {

    private final PaymentGatewayHttpClient client;

    @Override
    @Retry(name = "paymentGateway")
    @CircuitBreaker(name = "paymentGateway", fallbackMethod = "fallback")
    @TimeLimiter(name = "paymentGateway")
    public PaymentReference charge(Money amount, IdempotencyKey idempotencyKey) {
        var response = client.charge(new ChargeRequest(amount, idempotencyKey));
        return new PaymentReference(response.transactionId());
    }

    private PaymentReference fallback(Money amount, IdempotencyKey key, Throwable t) {
        throw new PaymentGatewayUnavailableException(key, t);
    }
}
// adapter/in/rest/order_handler.go
package handler

import (
    "net/http"

    "github.com/go-chi/chi/v5"
)

type OrderHandler struct {
    dispatcher UseCaseDispatcher
}

func (h *OrderHandler) Pay(w http.ResponseWriter, r *http.Request) {
    orderId := MustOrderID(chi.URLParam(r, "orderId"))
    key := MustIdempotencyKey(r.Header.Get("Idempotency-Key"))
    buyer := BuyerFromContext(r.Context())

    resp, err := h.dispatcher.Dispatch(r.Context(), PayOrderCommand{
        OrderID:        orderId,
        BuyerID:        buyer.ID,
        IdempotencyKey: key,
    })
    if err != nil {
        renderError(w, err)
        return
    }
    renderJSON(w, http.StatusOK, resp)
}

// app/usecase/pay_order.go
package usecase

import "context"

type PayOrderCommand struct {
    OrderID        OrderID
    BuyerID        BuyerID
    IdempotencyKey IdempotencyKey
}

type PayOrderResponse struct {
    OrderID   OrderID
    Status    OrderStatus
    Reference PaymentReference
}

type PayOrderHandler struct {
    orders     OrderRepository
    gateway    PaymentGateway
    idempotent IdempotencyStore
    events     DomainEventPublisher
}

func (h *PayOrderHandler) Handle(ctx context.Context, cmd PayOrderCommand) (PayOrderResponse, error) {
    return h.idempotent.ExecuteOnce(ctx, cmd.IdempotencyKey, func() (PayOrderResponse, error) {
        order, err := h.orders.FindByID(ctx, cmd.OrderID)
        if err != nil {
            return PayOrderResponse{}, &OrderNotFoundError{ID: cmd.OrderID}
        }

        ref, err := h.gateway.Charge(ctx, order.TotalAmount(), cmd.IdempotencyKey)
        if err != nil {
            return PayOrderResponse{}, err
        }
        order.MarkPaid(ref)
        if err := h.orders.Save(ctx, order); err != nil {
            return PayOrderResponse{}, err
        }
        h.events.Publish(ctx, order.PullEvents())
        return PayOrderResponse{OrderID: order.ID, Status: order.Status, Reference: ref}, nil
    })
}

// adapter/out/payment/gateway_adapter.go
package payment

import (
    "context"

    "github.com/sony/gobreaker/v2"
    retry "github.com/avast/retry-go/v4"
)

type PaymentGatewayAdapter struct {
    client  PaymentGatewayHTTPClient
    breaker *gobreaker.CircuitBreaker[PaymentReference]
}

func (a *PaymentGatewayAdapter) Charge(ctx context.Context, amount Money, key IdempotencyKey) (PaymentReference, error) {
    ref, err := a.breaker.Execute(func() (PaymentReference, error) {
        var result PaymentReference
        retryErr := retry.Do(func() error {
            resp, err := a.client.Charge(ctx, ChargeRequest{Amount: amount, IdempotencyKey: key})
            if err != nil {
                return err
            }
            result = PaymentReference{TransactionID: resp.TransactionID}
            return nil
        }, retry.Context(ctx))
        return result, retryErr
    })
    if err != nil {
        return PaymentReference{}, &PaymentGatewayUnavailableError{Key: key, Cause: err}
    }
    return ref, nil
}
// adapter/in/rest/order.controller.ts
import { Controller, Post, Param, Headers } from '@nestjs/common';

@Controller('v1/orders')
export class OrderController {
    constructor(private readonly dispatcher: UseCaseDispatcher) {}

    @Post(':orderId/pay')
    pay(
        @Param('orderId', OrderIdPipe) orderId: OrderId,
        @Headers('idempotency-key') rawKey: string,
        @CurrentBuyer() buyer: Buyer,
    ): Promise<PayOrderResponse> {
        const key = new IdempotencyKeyPipe().transform(rawKey);
        return this.dispatcher.dispatch(new PayOrderCommand(orderId, buyer.id, key));
    }
}

// app/usecase/pay-order.command.ts
export class PayOrderCommand implements UseCaseCommand<PayOrderResponse> {
    constructor(
        readonly orderId: OrderId,
        readonly buyerId: BuyerId,
        readonly idempotencyKey: IdempotencyKey,
    ) {}
}

export class PayOrderResponse {
    constructor(
        readonly orderId: OrderId,
        readonly status: OrderStatus,
        readonly reference: PaymentReference,
    ) {}
}

// app/usecase/pay-order.handler.ts
@InboundPort()
export class PayOrderHandler implements UseCaseHandler<PayOrderCommand, PayOrderResponse> {
    constructor(
        private readonly orders: OrderRepository,
        private readonly gateway: PaymentGateway,
        private readonly idempotency: IdempotencyStore,
        private readonly events: DomainEventPublisher,
    ) {}

    @Transactional()
    handle(cmd: PayOrderCommand): Promise<PayOrderResponse> {
        return this.idempotency.executeOnce(cmd.idempotencyKey, async () => {
            const order = await this.orders.findById(cmd.orderId);
            if (!order) throw new OrderNotFoundException(cmd.orderId);

            const ref = await this.gateway.charge(order.totalAmount(), cmd.idempotencyKey);
            order.markPaid(ref);
            await this.orders.save(order);
            this.events.publish(order.pullEvents());

            return new PayOrderResponse(order.id, order.status, ref);
        });
    }
}

// adapter/out/payment/payment-gateway.adapter.ts
@OutboundAdapter()
export class PaymentGatewayAdapter implements PaymentGateway {
    constructor(private readonly client: PaymentGatewayHttpClient) {}

    @Retry({ name: 'paymentGateway' })
    @CircuitBreaker({ name: 'paymentGateway' })
    async charge(amount: Money, key: IdempotencyKey): Promise<PaymentReference> {
        const resp = await this.client.charge({ amount, idempotencyKey: key });
        return new PaymentReference(resp.transactionId);
    }
}
# adapter/in/rest/order_router.py
from dataclasses import dataclass
from fastapi import APIRouter, Depends, Header

router = APIRouter()

@router.post("/v1/orders/{order_id}/pay")
async def pay(
    order_id: OrderId,
    idempotency_key: IdempotencyKey = Header(alias="Idempotency-Key"),
    buyer: Buyer = Depends(current_buyer),
    dispatcher: UseCaseDispatcher = Depends(),
) -> PayOrderResponse:
    return await dispatcher.dispatch(PayOrderCommand(
        order_id=order_id,
        buyer_id=buyer.id,
        idempotency_key=idempotency_key,
    ))

# app/usecase/pay_order.py
from dataclasses import dataclass

@dataclass(frozen=True)
class PayOrderCommand:
    order_id: OrderId
    buyer_id: BuyerId
    idempotency_key: IdempotencyKey

@dataclass(frozen=True)
class PayOrderResponse:
    order_id: OrderId
    status: OrderStatus
    reference: PaymentReference

class PayOrderHandler:
    def __init__(
        self,
        orders: OrderRepository,
        gateway: PaymentGateway,
        idempotency: IdempotencyStore,
        events: DomainEventPublisher,
    ) -> None:
        self._orders = orders
        self._gateway = gateway
        self._idempotency = idempotency
        self._events = events

    @transactional
    async def handle(self, cmd: PayOrderCommand) -> PayOrderResponse:
        async def _execute() -> PayOrderResponse:
            order = await self._orders.find_by_id(cmd.order_id)
            if order is None:
                raise OrderNotFoundError(cmd.order_id)

            ref = await self._gateway.charge(order.total_amount(), cmd.idempotency_key)
            order.mark_paid(ref)
            await self._orders.save(order)
            self._events.publish(order.pull_events())
            return PayOrderResponse(order.id, order.status, ref)

        return await self._idempotency.execute_once(cmd.idempotency_key, _execute)

# adapter/out/payment/gateway_adapter.py
class PaymentGatewayAdapter(PaymentGateway):
    def __init__(self, client: PaymentGatewayHttpClient) -> None:
        self._client = client

    @retry(name="paymentGateway")
    @circuit_breaker(name="paymentGateway")
    @time_limiter(name="paymentGateway")
    async def charge(self, amount: Money, key: IdempotencyKey) -> PaymentReference:
        resp = await self._client.charge(ChargeRequest(amount=amount, idempotency_key=key))
        return PaymentReference(transaction_id=resp.transaction_id)

Compare this with the first pass. Without the derivations:

  • Typed value objects (OrderId, IdempotencyKey, Money, PaymentReference) instead of raw strings
  • An exception hierarchy (OrderNotFoundException, PaymentGatewayUnavailableException)
  • Idempotency — mandatory and built in via executeOnce
  • Business logic — in order.markPaid(ref), invariants checked in the domain
  • Resilience — through declarative annotations (retry, circuit breaker, timeout)
  • The payment gateway — behind the PaymentGateway interface (a port), the implementation being an adapter
  • The response DTO — a typed record/dataclass

And most importantly: this is not the result of a genius prompt. It's the result of Claude knowing your project's rules. Any developer on the team will get the same solution for this brief, because the skills apply to all sessions identically.

And the prompt is no larger for it. "Implement the order-payment use case" — and Claude applies everything described above automatically.


What a "shared context" is, technically

The objection "why a methodology, AI writes the code anyway" stems from the implicit assumption that AI works alone. One prompt, one answer.

In reality AI always works in some context. The only question is what context that is:

  • Without a methodology: context = training data (the average of all developers in the world) + the prompt text (what the developer remembers to write).
  • With a methodology: context = your explicit rules (skills), your history of decisions (Memory Bank), your spec template (16 sections with typed fields).

These are three layers:

Layer 1: Skills (the rules of the game)

A Claude Code skill isn't a hint. It's the rules of the game, which Claude applies automatically every time it sees a matching context.

Example: ucp-api-design contains roughly the following rules (simplified):

  • URLs are always in kebab-case, resources in the plural
  • Action endpoints via POST /{resource}/{id}/{action}, not GET
  • Versioning in the URL: /v1/
  • Idempotency via the Idempotency-Key header
  • Errors per RFC 9457 Problem Details
  • Aliases for the current user: /users/me, not /users/current

These are all checkable rules. The skill can find a violation and flag it. AI writes an API → Claude applies these rules → out comes an API in the project's style, not the average one.

One skill = one aspect of the codebase. We currently have 6 skills (REST API, code style, tests, DDD tactics, Use Case Pattern, specifications) — each covering its own layer. All together — an executable team standard.

Layer 2: Memory Bank (what we decided once)

For each service in the repository there's a memory-bank/ — a folder with three to five markdown files describing:

  • Which Bounded Context this service implements
  • Which aggregates it has, which neighboring services exist
  • Which architectural decisions have already been made ("we use Outbox via PostgreSQL LISTEN/NOTIFY", "idempotency is stored in Redis with a 24h TTL")
  • Which open questions exist and why they're open

This is not documentation for people (though people can read it too). It's context for AI — Claude reads the Memory Bank first thing in every session and understands which service it's in.

Without a Memory Bank, Claude in every new session is like a new developer on day one: knows the language and stack, doesn't know your project. With a Memory Bank — like a veteran familiar with the history of decisions.

Layer 3: The spec template (a structure you can check against)

The most underrated layer. One of the readers put it better than I could:

"The least obvious part of your methodology isn't the AI agents, it's the template itself — 16 sections with levels of detail. Most of our specs fail the test of machine-checkability simply because they're written in prose. For a linter — AI or conventional — to catch anything, the spec has to be a structure of fields, not an essay."

Exactly. AI agents are a second-order derivative. They only work when there's a structure you can tune them to. If the spec is a prose essay, no agent will extract an integration contract or a state-transition matrix from it — there's simply nothing for it to parse.

A Use Case specification of 16 sections with three maturity levels (1/2/3) is the first-order derivative. The structure itself. Skills and the Memory Bank are the tuning on top of that structure. Without the structure they're useless, with it — powerful.

And that's the deep answer to "why a methodology if AI writes the code." Not for the AI, but so that the AI can check anything at all.


The inversion paradox: the smarter AI gets, the more you need a methodology

Most objections to a methodology in the age of AI stem from the intuition "AI got smarter → the developer doesn't need to know architecture → a methodology is even less necessary."

This intuition is wrong. The right way to put it is:

AI applies whatever rules you give it. Without rules — it takes the averages from training data. With rules — it follows yours. The more precise the AI, the more precisely it follows what you give it. And the more it matters that what you give it is well thought out.

An analogy: a powerful CNC machine cuts anything according to any program. If the program is bad, the machine will cut precisely badly. If there's no program at all — the machine doesn't work.

An AI code generator is a powerful machine. The methodology is its program. The more powerful the machine, the more it depends on the program, not the other way around.

Without a methodology you get: AI draws the "average program." In 5 years the project is a zoo of "average programs" from different Claude generations.

With a methodology you get: AI draws your program — consistently, coherently, accounting for your architectural decisions. In 5 years the project is a single organism, in which a new developer understands the code of any service in a day.


A historical parallel

This conversation has happened before. Several times.

Late 1990s: "Why do we need Spring when we have the Servlet API?"

The argument of the skeptics of the day: "You can write any service on bare Servlet API, it's all right there. Why add a framework with its own IoC and annotations?"

The answer turned out to be simple: compatibility. Spring set a shared context for all Java applications of that era. A team that wrote on Spring could easily switch between projects. A team without a framework — reinvented the wheel every time.

Today no one argues that Spring was the right move.

1980s: "Why POSIX if you can write the kernel from scratch?"

Every Unix-like vendor considered its system unique. AIX, HP-UX, Solaris, IRIX — a dozen incompatible systems. POSIX set a standard. It was criticized for bureaucracy, for compromises, for "killing brilliant ideas."

Today there are exactly two branches of Unix: Linux and BSD. Both POSIX-compatible. Without POSIX it would have been a zoo, and Linux would never have become dominant — there'd have been no application portability.

2010s: "Why a linter when the programmer is smart?"

ESLint, Checkstyle, Sonar. The same objections: "the programmer already knows how to write", "a linter slows down review." The answer — scale. On a team of three, all the rules are in everyone's head. On a team of 50 — without a linter the rules exist only on paper, and they're violated daily.

2020s: "Why a methodology when AI writes the code?"

The same class of objection. The same class of answer.

Not "why," but "here's why": compatibility, scale, consistency over time, shared context. A methodology doesn't cancel AI — it sets the frame within which AI stops being the "average of all developers in the world" and becomes your developer.


Conclusion

AI writes the code. That's true.

AI without a methodology is great for a solo prototype, a script, a one-off hypothesis check. That's true.

For a team, a product, and time — AI without a methodology turns into a zoo within a year.

With a methodology AI becomes ten times more valuable, because it steps outside the "average of training data" and works in your context.

Use Case Pattern is an attempt at such a methodology — open and adaptable. Four pillars:

  1. Pattern — layered architecture in which the use case is the primary unit
  2. Specification — 16 sections with three maturity levels (1/2/3), a machine-checkable structure rather than an essay
  3. Maturity levels 1–3 — from layered architecture to DDD + Hexagonal, chosen to fit the task
  4. AI agents as part of the methodology — Claude Code skills that apply the rules automatically

If you agree with the thesis "AI without a methodology is great for a prototype, for a team it isn't" — welcome. Here's what I suggest reading next:

But if you remain of the opinion "AI will do it all anyway" — close this article, it's not for you. I'm not campaigning to change your mind. Perhaps yours is exactly the context in which that's the right decision.