You ask GitHub Copilot to create a user registration system. Within seconds, it generates a 200-line function that handles validation, database operations, email sending, and logging—all in a single method. It works. Tests pass. You ship it.
Six months later, you need to change the email provider. The email logic is tangled with database transactions. You need to add SMS verification. The validation rules are buried in conditionals. You want to unit test the business logic. Everything is coupled to everything else.
This is the monolithic code generation problem—AI's systematic tendency to create tightly coupled, single-responsibility-violating code that optimizes for immediate functionality over long-term maintainability.
The Speed-Quality Paradox: GitClear research found that while developers write code 55% faster with Copilot, code churn—the percentage of lines reverted or updated within two weeks—is projected to double compared to pre-AI baselines.
In this comprehensive guide, we'll explore why AI generates monolithic code, review SOLID principles through the lens of AI-assisted development, and provide actionable strategies including architectural prompting, iterative refactoring workflows, complexity metrics, and automated quality gates.
Why AI Creates Monoliths
Single-Turn Optimization
AI models optimize for providing a complete, working solution in a single response. This creates a fundamental tension with good architecture:
- Completeness bias: AI tries to solve the entire problem in one function
- Context limitation: It doesn't see your existing architecture
- Training data patterns: Stack Overflow answers and tutorials favor simple, self-contained examples
- No future awareness: AI doesn't consider how requirements might evolve
Training Data Reflects Reality
Most code in public repositories—the training data—doesn't follow SOLID principles. AI learns from:
- Prototype code that was never refactored
- Tutorial examples optimized for teaching, not production
- Legacy codebases with accumulated technical debt
- Quick fixes and hotpatches
AI Code Quality Statistics
- 55% faster coding with Copilot but 2x code churn increase projected
- 26% of developers now using AI coding tools
- Target cognitive complexity: <15 per function
- AI violates DRY principle—more prone to code duplication
Code Quality Research: The Evidence
GitClear's Findings (2024)
GitClear's comprehensive whitepaper revealed "disconcerting trends for maintainability":
- Code churn doubling: Lines reverted or updated within two weeks projected to 2x from 2021 baseline
- Added/copied code increasing: More new code, less refactoring of existing code
- DRY violations: AI-generated code "more resembles an itinerant contributor, prone to violate the DRY-ness of repos visited"
- Suggestion bias: "Inundated with suggestions for added code, but never suggestions for updating, moving, or deleting code"
The Coupling Problem
Without architectural guidance, all logic ends up directly inside UI files. Calculations, business rules, and data models become tightly coupled:
// AI-GENERATED MONOLITHIC CODE - Everything in one React component
function UserRegistration() {
const [form, setForm] = useState({ email: '', password: '', name: '' });
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
// Validation logic embedded in component
const newErrors = {};
if (!form.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
newErrors.email = 'Invalid email format';
}
if (form.password.length < 8) {
newErrors.password = 'Password must be 8+ characters';
}
if (!form.password.match(/[A-Z]/)) {
newErrors.password = 'Password needs uppercase letter';
}
if (!form.name.trim()) {
newErrors.name = 'Name is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setLoading(true);
try {
// Database logic embedded
const hashedPassword = await bcrypt.hash(form.password, 10);
const user = await db.collection('users').insertOne({
email: form.email.toLowerCase(),
password: hashedPassword,
name: form.name.trim(),
createdAt: new Date(),
verified: false
});
// Email logic embedded
const token = jwt.sign({ userId: user.insertedId }, SECRET);
await transporter.sendMail({
to: form.email,
subject: 'Verify your account',
html: `<a href="${BASE_URL}/verify?token=${token}">Click</a>`
});
// Analytics logic embedded
await analytics.track('user_registered', { userId: user.insertedId });
router.push('/check-email');
} catch (error) {
if (error.code === 11000) {
setErrors({ email: 'Email already exists' });
} else {
setErrors({ general: 'Registration failed' });
}
} finally {
setLoading(false);
}
};
return (/* JSX */);
}
// Problems:
// - 6+ responsibilities in one function
// - Can't unit test validation without mocking everything
// - Can't change email provider without touching this file
// - Can't reuse validation elsewhere
// - Database, email, analytics all tightly coupled
SOLID Principles Review
Before we fix AI-generated code, let's review SOLID principles—the foundation of maintainable architecture:
S - Single Responsibility Principle (SRP)
A class should have only one reason to change. AI violates this by cramming validation, persistence, notifications, and logging into single functions.
O - Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. AI generates code that requires modification (not extension) to add new behaviors.
L - Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types. AI often creates inheritance hierarchies that break this principle.
I - Interface Segregation Principle (ISP)
No client should be forced to depend on interfaces it doesn't use. AI tends to create large, monolithic interfaces rather than focused ones.
D - Dependency Inversion Principle (DIP)
High-level modules shouldn't depend on low-level modules; both should depend on abstractions. AI hardcodes concrete dependencies instead of using injection.
"AI tools and models are powerful, but they need clear instructions to follow good design practices. If you want modular and maintainable code, structure your prompts to reflect SOLID ideas."
Solution #1: Architectural Prompting
The key to getting well-architected code from AI is explicit prompting that enforces separation of concerns.
The SOLID Prompting Framework
// VAGUE PROMPT (Gets monolithic code):
"Create a user registration system with email verification."
// ARCHITECTURAL PROMPT (Gets modular code):
"Create a user registration system following Clean Architecture:
STRUCTURE (separate files/classes for each):
1. Domain Layer:
- User entity (business rules only)
- UserValidator (validation logic)
- RegistrationResult (success/failure representation)
2. Application Layer:
- RegisterUserUseCase (orchestrates the flow)
- IUserRepository interface
- IEmailService interface
- IAnalyticsService interface
3. Infrastructure Layer:
- MongoUserRepository implements IUserRepository
- SendGridEmailService implements IEmailService
- MixpanelAnalyticsService implements IAnalyticsService
4. Presentation Layer:
- RegistrationController (HTTP handling only)
- RegistrationForm component (UI only)
SOLID REQUIREMENTS:
- Each class has ONE responsibility
- Use dependency injection (no 'new' for services)
- Define interfaces before implementations
- Keep functions under 20 lines
- Cognitive complexity under 10 per function
Return each layer as a separate code block with file paths."
Example: Well-Architected Output
// domain/entities/User.ts
export class User {
constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string,
public readonly passwordHash: string,
public readonly verified: boolean = false,
public readonly createdAt: Date = new Date()
) {}
static create(email: string, name: string, passwordHash: string): User {
return new User(
crypto.randomUUID(),
email.toLowerCase().trim(),
name.trim(),
passwordHash,
false,
new Date()
);
}
}
// domain/validators/UserValidator.ts
export interface ValidationResult {
isValid: boolean;
errors: Record<string, string>;
}
export class UserValidator {
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
private static readonly MIN_PASSWORD_LENGTH = 8;
validate(email: string, password: string, name: string): ValidationResult {
const errors: Record<string, string> = {};
if (!this.isValidEmail(email)) {
errors.email = 'Invalid email format';
}
const passwordError = this.validatePassword(password);
if (passwordError) {
errors.password = passwordError;
}
if (!this.isValidName(name)) {
errors.name = 'Name is required';
}
return {
isValid: Object.keys(errors).length === 0,
errors
};
}
private isValidEmail(email: string): boolean {
return UserValidator.EMAIL_REGEX.test(email);
}
private validatePassword(password: string): string | null {
if (password.length < UserValidator.MIN_PASSWORD_LENGTH) {
return `Password must be at least ${UserValidator.MIN_PASSWORD_LENGTH} characters`;
}
if (!/[A-Z]/.test(password)) {
return 'Password must contain an uppercase letter';
}
return null;
}
private isValidName(name: string): boolean {
return name.trim().length > 0;
}
}
// application/interfaces/IUserRepository.ts
export interface IUserRepository {
save(user: User): Promise<void>;
findByEmail(email: string): Promise<User | null>;
existsByEmail(email: string): Promise<boolean>;
}
// application/interfaces/IEmailService.ts
export interface IEmailService {
sendVerificationEmail(email: string, token: string): Promise<void>;
}
// application/use-cases/RegisterUserUseCase.ts
export class RegisterUserUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly emailService: IEmailService,
private readonly passwordHasher: IPasswordHasher,
private readonly tokenGenerator: ITokenGenerator,
private readonly validator: UserValidator
) {}
async execute(input: RegisterUserInput): Promise<RegisterUserResult> {
// 1. Validate input
const validation = this.validator.validate(
input.email, input.password, input.name
);
if (!validation.isValid) {
return RegisterUserResult.validationFailed(validation.errors);
}
// 2. Check uniqueness
const exists = await this.userRepository.existsByEmail(input.email);
if (exists) {
return RegisterUserResult.emailTaken();
}
// 3. Create user
const passwordHash = await this.passwordHasher.hash(input.password);
const user = User.create(input.email, input.name, passwordHash);
// 4. Persist
await this.userRepository.save(user);
// 5. Send verification
const token = this.tokenGenerator.generate(user.id);
await this.emailService.sendVerificationEmail(user.email, token);
return RegisterUserResult.success(user.id);
}
}
Solution #2: Iterative Refactoring with AI
When AI generates monolithic code, use iterative prompting to break it down:
Step 1: Extract Validation
Extract the validation logic from handleSubmit into a separate
UserValidator class. The class should:
- Have a single validate() method
- Return a ValidationResult object with isValid and errors
- Be unit testable without any external dependencies
- Follow the Single Responsibility Principle
Step 2: Extract Repository
Extract the database operations into a UserRepository class that:
- Implements an IUserRepository interface
- Has methods: save(user), findByEmail(email), existsByEmail(email)
- Handles only data persistence, no business logic
- Can be mocked in tests
Step 3: Extract Services
Extract the email sending logic into an EmailService that:
- Implements an IEmailService interface
- Has a single sendVerificationEmail(email, token) method
- Contains no business logic, only email delivery
- Can be swapped for different providers (SendGrid, AWS SES, etc.)
Step 4: Create Use Case
Create a RegisterUserUseCase class that:
- Takes all dependencies through constructor injection
- Orchestrates the registration flow
- Contains the business logic
- Returns a result object (not throws exceptions for expected cases)
- Has cognitive complexity under 10
Solution #3: Complexity Metrics
Use metrics to detect when AI-generated code needs refactoring:
Cyclomatic Complexity
// Cyclomatic Complexity = 1 + number of decision points
// Complexity: 1 (no decisions)
function greet(name) {
return `Hello, ${name}!`;
}
// Complexity: 4 (3 decision points)
function validateUser(user) {
if (!user.email) { // +1
return { valid: false, error: 'Email required' };
}
if (!user.password) { // +1
return { valid: false, error: 'Password required' };
}
if (user.password.length < 8) { // +1
return { valid: false, error: 'Password too short' };
}
return { valid: true };
}
// Target: Keep under 10 per function
Cognitive Complexity
// Cognitive Complexity considers nesting depth
// Cognitive Complexity: 1
function simple(a) {
if (a) { // +1 for if
return true;
}
return false;
}
// Cognitive Complexity: 7 (nested structures compound)
function complex(a, b, c) {
if (a) { // +1
if (b) { // +2 (nested)
if (c) { // +3 (double nested)
return 'all';
}
}
}
return 'none';
}
// Target: Keep under 15 per function
Metrics Comparison Table
| Metric | Target | Warning | Critical |
|---|---|---|---|
| Cyclomatic Complexity | <10 | 10-20 | >20 |
| Cognitive Complexity | <15 | 15-25 | >25 |
| Function Length | <50 lines | 50-100 lines | >100 lines |
| Class Length | <300 lines | 300-500 lines | >500 lines |
| Parameters | <4 | 4-6 | >6 |
Solution #4: Automated Quality Gates
ESLint Complexity Rules
// .eslintrc.js
module.exports = {
rules: {
// Cyclomatic complexity
'complexity': ['error', { max: 10 }],
// Maximum depth of nested blocks
'max-depth': ['error', { max: 4 }],
// Maximum lines per function
'max-lines-per-function': ['error', {
max: 50,
skipBlankLines: true,
skipComments: true
}],
// Maximum parameters
'max-params': ['error', { max: 4 }],
// Maximum statements per function
'max-statements': ['error', { max: 15 }],
// Cognitive complexity (via eslint-plugin-sonarjs)
'sonarjs/cognitive-complexity': ['error', 15],
// No duplicate code
'sonarjs/no-duplicate-string': 'error',
'sonarjs/no-identical-functions': 'error',
}
};
GitHub Actions Quality Gate
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: Quality Gate Check
uses: sonarsource/sonarqube-quality-gate-action@master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Real-World Example: Refactoring AI Output
After Refactoring: Clean Architecture
// presentation/hooks/useRegistration.ts
export function useRegistration() {
const [state, setState] = useState<RegistrationState>({
status: 'idle',
errors: {}
});
const registerUser = useCallback(async (input: RegisterUserInput) => {
setState({ status: 'loading', errors: {} });
const result = await registrationService.register(input);
if (result.isSuccess) {
setState({ status: 'success', errors: {} });
return true;
}
setState({ status: 'error', errors: result.errors });
return false;
}, []);
return { ...state, registerUser };
}
// presentation/components/RegistrationForm.tsx
export function RegistrationForm() {
const { status, errors, registerUser } = useRegistration();
const router = useRouter();
const handleSubmit = async (data: FormData) => {
const success = await registerUser({
email: data.email,
password: data.password,
name: data.name
});
if (success) {
router.push('/check-email');
}
};
return (
<Form onSubmit={handleSubmit}>
<Input name="email" error={errors.email} />
<Input name="password" type="password" error={errors.password} />
<Input name="name" error={errors.name} />
{errors.general && <Alert>{errors.general}</Alert>}
<Button loading={status === 'loading'}>Register</Button>
</Form>
);
}
// The component is now:
// - ~30 lines instead of 100+
// - UI concerns only
// - Easy to test with mocked hook
// - Reusable across different registration flows
Benefits of Refactoring
| Aspect | Before (Monolithic) | After (Clean Architecture) |
|---|---|---|
| Testability | Requires mocking 5+ dependencies | Each unit testable in isolation |
| Change email provider | Modify component + test | Swap implementation only |
| Add SMS verification | Modify handleSubmit | Add new service + inject |
| Reuse validation | Copy-paste code | Import validator class |
| Cognitive complexity | 25+ | <10 per function |
Key Takeaways
Architecture Essentials
- Speed ≠ Quality: AI makes you 55% faster but doubles code churn—fast technical debt is still technical debt
- Prompt for Architecture: AI won't apply SOLID unless you ask—use architectural prompts specifying layers, interfaces, and responsibilities
- Iterate, Don't Accept: Use multi-step prompting to extract concerns—break down monolithic output into focused, testable units
- Measure Complexity: Target cyclomatic <10, cognitive <15, functions <50 lines—if metrics fail, refactor before merging
- Automate Quality Gates: Use ESLint, SonarQube, and custom checkers in CI/CD—block PRs that exceed complexity thresholds
- You Are the Architect: Copilot is not your architect—provide structure, or it will generate "working code that slowly turns into technical debt"
Conclusion
AI coding assistants are optimized for speed and immediate correctness, not architectural elegance. Left unchecked, they'll generate monolithic functions that work today but become maintenance nightmares tomorrow.
The solution isn't to avoid AI—it's to guide it. Use architectural prompting that explicitly requests SOLID principles. Apply iterative refactoring to break down monolithic output. Measure complexity metrics and automate quality gates. Remember: you are the architect, and AI is your assistant, not the other way around.
In our next article, we'll explore Error Handling Inadequacy in AI-Generated Code, examining why AI generates "happy path" code that lacks robust error handling and how to build resilient systems.