Constraints & Guardrails

40 min intermediate Lesson 5

Learning Outcomes

  • Write positive constraints (DO use) and negative constraints (DO NOT) effectively
  • Specify output format to get code structured exactly as needed
  • Set appropriate length and complexity bounds for generated code
  • Apply production guardrails for code that handles accounts, data and APIs
  • Use the "boundary" approach to define the edges of acceptable output

Lesson Plan

Segment Duration Topic
Intro 3 min Why AI needs boundaries
Explain 8 min Positive vs negative constraints
Demo 7 min Output format specification
Explain 7 min Length and complexity bounds
Demo 8 min Production rails for code generation
Practice 5 min The boundary approach in action
Wrap-up 2 min Key takeaways, preview next lesson

Before You Begin

Pre-work:

  • Complete Lessons 1-4 (Anatomy through Few-Shot)
  • Think of a time AI generated code that was technically correct but wrong for your context
  • Review your project's coding standards or linting rules

Shopping List:

  • Any AI coding tool (Claude Code, Cursor, or Codex CLI)
  • Your project's linting configuration (ESLint, Pylint, etc.) for reference
  • A list of "things AI should never do" in your codebase

1 Positive Constraints vs Negative Constraints

Constraints tell AI what the boundaries of acceptable output are. They come in two forms:

Positive constraints (DO use): Tell AI what TO do, what TO include, what patterns TO follow.

Constraints:
- Use TypeScript strict mode (no implicit any)
- Handle all error cases with custom error classes
- Include JSDoc comments on all public methods
- Use dependency injection for all external services
- Return immutable objects (use Readonly<T> or Object.freeze)

Negative constraints (DO NOT): Tell AI what to AVOID, what NOT to include, what anti-patterns to skip.

Constraints:
- Do NOT use "any" type anywhere
- Do NOT mutate function parameters
- Do NOT use console.log (use the logger service instead)
- Do NOT use synchronous file system operations
- Do NOT import from internal/private modules

Which type is more effective?

Both have their place, but positive constraints are generally stronger. Here's why:

  • Positive: "Use parameterised queries for all database access" — clear, actionable, unambiguous
  • Negative: "Don't concatenate user input into SQL strings" — tells AI what to avoid but doesn't specify the alternative

The ideal approach combines both — positive constraints for the primary path, negative constraints for known pitfalls:

Database access rules:
- Use parameterised queries for ALL user-provided values
- Use the query builder (knex) rather than raw SQL where possible
- Do NOT concatenate variables into SQL strings
- Do NOT use SELECT * — always specify columns
- Do NOT run queries outside a transaction for multi-step operations
NOTE
Key Insight
Think of positive constraints as guide rails and negative constraints as warning signs. Guide rails keep you on the path; warning signs call out specific cliffs. Use both, but prioritise the guide rails.

Common positive constraints for code generation:

- Use early returns to avoid nested if-else
- Extract magic numbers into named constants
- Keep functions under 30 lines
- Use descriptive variable names (no single letters except loop counters)
- Handle the loading, error, and empty states for all data fetching

Common negative constraints for code generation:

- Do NOT use var (use const/let)
- Do NOT use .then() chains (use async/await)
- Do NOT disable linting rules with comments
- Do NOT use eval() or Function() constructor
- Do NOT store secrets in source code

2 Output Format Specification

One of the most powerful constraints is telling AI exactly what shape the output should take. Without format specification, AI makes assumptions about file structure, export style, and organisation.

Specifying file structure:

Create the user validation module with this structure:

File: src/validators/user.validator.ts
- Export a validateCreateUser function
- Export a validateUpdateUser function  
- Export the validation schemas as named exports
- Do NOT export a default

Import validation library: use zod
Import types from: ../types/user.types.ts

Specifying function signatures:

The function must have this exact signature:

function processPayment(
  orderId: string,
  amount: MoneyAmount,
  method: PaymentMethod,
  options?: PaymentOptions
): Promise<PaymentResult>

Where:
- MoneyAmount = { value: number; currency: CurrencyCode }
- PaymentResult = { success: boolean; transactionId?: string; error?: PaymentError }

Implement the body. Do not change the signature.

Specifying response shapes:

The API endpoint must return responses in this exact format:

Success (200):
{
  "data": { ... },
  "meta": {
    "requestId": "uuid",
    "timestamp": "ISO-8601"
  }
}

Error (4xx/5xx):
{
  "error": {
    "code": "SNAKE_CASE_ERROR_CODE",
    "message": "Human-readable message",
    "details": [ ... ] // optional, for validation errors
  },
  "meta": {
    "requestId": "uuid",
    "timestamp": "ISO-8601"
  }
}

Specifying code organisation:

Organise the code in this order within the file:
1. Imports (external libraries first, then internal modules)
2. Type definitions and interfaces
3. Constants
4. Helper functions (private, not exported)
5. Main exported functions
6. Default export (if applicable)
TIP
Tip
Format constraints are especially useful when the generated code must integrate with existing code. If your API always returns { data, meta }, specify that — otherwise AI might return just the raw data, and you'll need to refactor to match your conventions.

The "looks like this" shortcut:

When describing format is tedious, show a skeleton:

Generate the component following this skeleton:

import { ... } from '...';
import styles from './ComponentName.module.css';

interface ComponentNameProps {
  // props here
}

export const ComponentName: FC<ComponentNameProps> = ({ ...props }) => {
  // hooks at the top
  // handlers in the middle
  // return JSX at the bottom
  return (
    <div className={styles.container}>
      {/* content */}
    </div>
  );
};

This is a hybrid of format specification and one-shot example — it shows structure without implementation.


3 Length and Complexity Bounds

AI tends toward verbosity. Without bounds, it will often produce code that's longer, more abstracted, and more complex than necessary. Setting explicit bounds prevents over-engineering.

Length constraints:

Keep the implementation simple:
- Maximum 50 lines for the main function
- No more than 3 levels of nesting
- If a helper is needed, it should be under 20 lines
- Total file should not exceed 150 lines
Write a MINIMAL implementation:
- Solve the core problem with the least code possible
- No error handling beyond what's critical for data integrity
- No logging, metrics, or observability (we'll add that later)
- No configuration — hardcode values, we'll extract them next

Complexity constraints:

Keep complexity low:
- No generics unless absolutely necessary for type safety
- No more than 2 type parameters on any function
- Prefer simple if/else over complex ternaries
- Use a switch statement instead of object lookup if there are fewer than 5 cases
- No metaprogramming (no Proxy, Reflect, or dynamic property access)
Avoid premature abstraction:
- Do NOT create a base class
- Do NOT add a factory pattern
- Do NOT create interfaces for things with only one implementation
- Just write the concrete implementation directly
- We'll abstract later if a second use case emerges

When to set bounds explicitly:

  • Prototyping: "Give me the simplest possible version. No error handling, no edge cases, just the happy path in under 30 lines."
  • Production: "Full implementation with error handling, input validation, logging, and comprehensive JSDoc comments."
  • Learning: "Simple implementation with detailed comments explaining each decision."

Scaling constraints based on task phase:

Phase 1 (Prototype): 
Build the feature with these limits:
- Happy path only
- In-memory storage (no database)
- No authentication check
- Console.log instead of proper logging

Phase 2 (Harden):
Now add production concerns:
- Proper error handling for all failure modes
- Replace in-memory with PostgreSQL queries
- Add auth middleware check
- Replace console.log with structured logger
WARNING
Watch Out
Without complexity bounds, AI will often produce enterprise-level abstractions for simple problems — creating factories, builders, abstract base classes, and strategy patterns for code that only needs a function. Be explicit about the simplicity level you want.

The "just enough" principle:

Implement this with the minimum viable complexity:
- If a function works, don't wrap it in a class
- If a class works, don't add an interface
- If an interface works, don't add a generic
- If a generic works, don't add a higher-kinded type

Only add abstraction when there's a concrete second use case, not
"in case we need it later."

4 Production Rails for Code Generation

Certain categories of code need explicit constraints. Without them, AI might produce code that works but leaks data, fails badly, or ships with dangerous defaults.

Accounts and sessions:

Constraints for this sign-in endpoint:
- NEVER return the password hash in any response
- NEVER log tokens, passwords, or session IDs
- ALWAYS set HttpOnly and Secure flags on cookies
- ALWAYS regenerate session ID after successful login
- Rate limit to 5 attempts per minute per IP
- Lock account after 10 failed attempts

Data handling:

Data safety constraints:
- NEVER include PII (email, name, phone) in log messages
- NEVER store unencrypted sensitive data (SSN, card numbers)
- ALWAYS sanitise user input before database insertion
- ALWAYS validate file uploads (type, size, content)
- ALWAYS use parameterised queries, never string interpolation

API design:

API safety constraints:
- NEVER expose internal IDs in public-facing responses (use UUIDs)
- NEVER return stack traces in production error responses
- ALWAYS validate Content-Type header matches expected format
- ALWAYS set appropriate CORS headers (not wildcard in production)
- ALWAYS paginate list endpoints (max 100 items per page)
- Include rate limiting headers (X-RateLimit-Remaining)

Dependency and environment:

Environment constraints:
- Do NOT read environment variables directly — use the config service
- Do NOT install new dependencies without listing them
- Do NOT use eval(), new Function(), or child_process.exec with user input
- Do NOT write to the filesystem outside the designated temp directory
- Do NOT make network requests to URLs provided by users without validation

Building a project rules template:

Collect your constraints into a reusable template that you include at the start of any prompt that touches accounts or data:

## Project Rules (include in all account/data prompts)

1. Input: validate and sanitise all user input at the boundary
2. Output: never expose internal errors, IDs, or system details
3. Storage: encrypt at rest, parameterise queries, no raw SQL
4. Transport: HTTPS only, secure cookie flags, proper CORS
5. Logging: no PII, no secrets, no tokens in logs
6. Dependencies: pin versions, audit regularly, no eval
NOTE
Key Insight
Constraints on data and accounts are the one area where being repetitive and explicit is better than being concise. Say it clearly every time. AI doesn't get annoyed by repetition — it responds to emphasis. The more explicitly you state a requirement, the more likely it is to be followed.

5 The "Boundary" Approach

The boundary approach defines the acceptable space for AI output by specifying what's in bounds and what's out of bounds. Rather than micromanaging the implementation, you define the edges.

The pattern:

Build [X] within these boundaries:

IN SCOPE:
- [What the code SHOULD handle]
- [What cases it SHOULD cover]
- [What technologies it SHOULD use]

OUT OF SCOPE:
- [What to explicitly NOT handle]
- [What to defer to a later iteration]
- [What's another team's responsibility]

QUALITY BOUNDARY:
- [Minimum acceptable quality]
- [Maximum acceptable complexity]

Example: Building a search feature

Build a product search API endpoint within these boundaries:

IN SCOPE:
- Full-text search by product name and description
- Filter by: category, price range, in-stock status
- Sort by: relevance, price (asc/desc), newest
- Pagination with cursor-based navigation
- Response time under 200ms for typical queries

OUT OF SCOPE:
- Typo correction / "did you mean" suggestions (Phase 2)
- Search analytics / tracking (separate service)
- Personalised results (requires ML pipeline, not yet built)
- Image-based search
- Geo-location filtering

QUALITY BOUNDARY:
- Minimum: works correctly for all filter combinations, handles empty results
- Maximum: don't build a custom search engine — use PostgreSQL full-text 
  search. If we need Elasticsearch later, we'll migrate.

Example: Building a form component

Build a multi-step checkout form within these boundaries:

IN SCOPE:
- Steps: shipping address, payment method, review & confirm
- Field validation on blur and on submit
- Progress indicator showing current step
- Ability to go back to previous steps (preserving data)
- Submit button disabled until current step is valid

OUT OF SCOPE:
- Address autocomplete (third-party API, different ticket)
- Saved payment methods (requires vault integration)
- Guest vs logged-in user differences (treat all as logged in for now)
- Mobile-specific layout (responsive CSS is fine, no separate mobile UX)

QUALITY BOUNDARY:
- Minimum: all fields validate, steps navigate correctly, data submits
- Maximum: don't build a generic multi-step form framework. Build THIS 
  checkout form. If we need another multi-step form, we'll extract shared 
  logic then.

Why the boundary approach works:

  1. Prevents scope creep — AI won't add "nice to have" features you'll need to maintain
  2. Sets expectations — you won't be surprised by what's missing
  3. Enables iteration — OUT OF SCOPE items become your next prompt
  4. Communicates intent — future developers (including yourself) understand what was deliberately excluded
TIP
Tip
The OUT OF SCOPE section is often more valuable than the IN SCOPE section. Without it, AI will try to be helpful by adding features you didn't ask for — address autocomplete, animation, caching, analytics — that create maintenance burden and distract from the core task.

Combining boundaries with other constraint types:

Build the notification service within these boundaries:

IN SCOPE: email and push notifications for order status changes
OUT OF SCOPE: SMS, in-app notifications, notification preferences UI

Positive constraints:
- Use the existing EmailService and PushService interfaces
- Queue notifications for async delivery (use Bull/Redis)
- Include retry logic (3 attempts, exponential backoff)

Negative constraints:
- Do NOT send notifications synchronously in the request path
- Do NOT store notification content in the database (just delivery status)
- Do NOT add new dependencies — use what's already in package.json

Format:
- One file: src/services/notification.service.ts
- Export a single NotificationService class
- Methods: notifyOrderShipped, notifyOrderDelivered, notifyOrderCancelled

This combines boundaries (scope), positive constraints (what to do), negative constraints (what to avoid), and format specification (structure) into a comprehensive but clear prompt.


Questions & Answers

Q: How many constraints is too many? Can I over-constrain?
Yes. If you have more than 10-12 constraints, AI starts to drop some. Prioritise: put the most critical constraints first, and consider whether some are obvious enough to omit. If your constraints are mostly standard best practices (like "use const"), they might be better placed in a rules file that applies to all prompts rather than repeated each time.
Q: What if AI violates a constraint I explicitly stated?
This happens more often with negative constraints deep in a long prompt. Fixes: move the violated constraint to the top, restate it more emphatically, or make it a positive constraint instead. "Do NOT use any" is less effective than "Use explicit types for every variable and parameter — never use any." If it persists, add an example showing the correct approach.
Q: Should constraints go in the prompt or in a rules file?
Stable, project-wide constraints belong in rules files (CLAUDE.md, .cursorrules, etc.) where they apply to every interaction. Task-specific constraints go in the prompt. If you're repeating the same constraints in more than 3 prompts, move them to a rules file. The rule of thumb: constraints about your project go in config; constraints about this specific task go in the prompt.
Q: How do I handle constraints that conflict with each other?
Prioritise explicitly. If you want both "keep functions short" and "handle all error cases," acknowledge the tension: "Prioritise complete error handling over line count. If a function needs 40 lines to handle all cases, that's acceptable." Without explicit priority, AI will optimise for whichever constraint it hits first — which may not be the one you care about most.

Key Takeaways

  1. Positive constraints guide, negative constraints warn — use both, but lead with what you want (not just what you don't want).
  2. Format specification eliminates integration friction — tell AI exactly what shape the output should take.
  3. Set complexity bounds explicitly — without them, AI defaults to over-engineering.
  4. Safety constraints should be loud and repetitive — never assume AI will "know" to be secure.
  5. The boundary approach defines the space — IN SCOPE, OUT OF SCOPE, and QUALITY BOUNDARY together prevent scope creep and set clear expectations.

Next Steps: In Lesson 6 — System Prompts & Rules Files, we'll learn how to encode your constraints permanently so they apply to every AI interaction in your project without repetition.