Tuesday, February 17, 2026

CRAFTING PROMPTS FOR ARCHITECTURALLY EXCELLENT CODE GENERATION


 

THE ART AND SCIENCE OF GUIDING LLMS
TOWARD SOFTWARE ARCHITECTURE MASTERY

INTRODUCTION: BEYOND SIMPLE CODE GENERATION

When we ask a large language model to generate code, we often receive syntactically correct solutions that solve the immediate problem. However, there exists a vast chasm between code that works and code that embodies sound architectural principles, supports long-term maintenance, and provides a stable foundation for evolving requirements. This tutorial explores the sophisticated art of prompt engineering specifically designed to elicit architecturally excellent code from language models.

The challenge we face is multifaceted. An LLM, despite its impressive capabilities, does not inherently understand the broader context of your system, the quality attributes you value most, or the architectural patterns that best serve your domain. Without explicit guidance, it will generate code that reflects statistical patterns from its training data rather than the carefully considered architectural decisions your project demands. Therefore, we must learn to communicate our architectural vision through prompts that are simultaneously precise and comprehensive.

Throughout this tutorial, we will explore concrete techniques for embedding architectural requirements into your prompts. We will examine how to specify quality attributes, request specific patterns, ensure proper documentation, and create code that serves as a robust backbone for implementation. Each concept will be illustrated with practical examples that demonstrate the difference between naive prompting and architecturally informed prompt engineering.

UNDERSTANDING ARCHITECTURAL EXCELLENCE

Before we can prompt an LLM to generate excellent architecture, we must first understand what constitutes architectural excellence. Architecture is not merely about organizing code into classes and modules. It represents the fundamental decisions about how a system is structured, how its components interact, and how it achieves its quality attributes while remaining adaptable to change.

Quality attributes form the foundation of architectural decision-making. These non-functional requirements include performance, scalability, maintainability, testability, security, and many others. Each quality attribute influences architectural choices in profound ways. For instance, a system optimized for performance might employ caching strategies and denormalized data structures, while a system prioritizing maintainability might favor clear separation of concerns even at the cost of some performance overhead.

Architectural patterns provide proven solutions to recurring problems. While many developers are familiar with the Gang of Four design patterns, architectural patterns operate at a higher level of abstraction. The Layered Architecture pattern separates concerns into horizontal layers such as presentation, business logic, and data access. The Hexagonal Architecture pattern, also known as Ports and Adapters, isolates the core business logic from external concerns. The Event-Driven Architecture pattern enables loose coupling through asynchronous message passing. Each pattern brings specific benefits and trade-offs that must be understood and communicated to the LLM.

Documentation serves as the bridge between code and understanding. Well-documented code explains not just what it does, but why it does it. It captures the architectural decisions, the rationale behind pattern choices, and the constraints that shaped the design. When we prompt an LLM, we must explicitly request this level of documentation, as the model will not provide it by default.

PROMPT ENGINEERING FUNDAMENTALS FOR ARCHITECTURE

The foundation of effective architectural prompting lies in specificity and context. A prompt that simply asks for "a user authentication system" will yield generic code that may or may not align with your architectural vision. Instead, we must craft prompts that establish context, specify constraints, and articulate our architectural expectations.

Consider the difference between these two approaches. A naive prompt might read: "Create a user authentication service." This provides minimal guidance, leaving the LLM to make arbitrary decisions about structure, patterns, and quality attributes. An architecturally informed prompt would instead specify: "Create a user authentication service following hexagonal architecture principles. The core domain should be isolated from infrastructure concerns through ports and adapters. Implement the authentication logic in the domain layer, define port interfaces for user repository and password hashing, and provide adapter implementations for a PostgreSQL database and bcrypt hashing. Ensure the code is testable by allowing dependency injection of adapters. Include comprehensive documentation explaining the architectural decisions and how the hexagonal pattern benefits this particular use case."

The second prompt provides the LLM with a clear architectural framework. It specifies the pattern to use, explains how components should be organized, identifies the key abstractions, and requests documentation of architectural decisions. This level of detail guides the LLM toward generating code that aligns with your architectural vision.

Let us examine a concrete example that demonstrates this principle. We will start with a simple authentication domain model and gradually build up the architectural layers.

// Domain layer - Core business logic isolated from infrastructure
// This represents the heart of our hexagonal architecture where
// business rules live independent of external concerns

public class User {
    private final String userId;
    private final String username;
    private final String hashedPassword;
    private final UserStatus status;
    
    // Constructor enforces invariants at creation time
    // This ensures we never have a User object in an invalid state
    public User(String userId, String username, String hashedPassword) {
        if (userId == null || userId.trim().isEmpty()) {
            throw new IllegalArgumentException("User ID cannot be null or empty");
        }
        if (username == null || username.trim().isEmpty()) {
            throw new IllegalArgumentException("Username cannot be null or empty");
        }
        if (hashedPassword == null || hashedPassword.trim().isEmpty()) {
            throw new IllegalArgumentException("Hashed password cannot be null or empty");
        }
        
        this.userId = userId;
        this.username = username;
        this.hashedPassword = hashedPassword;
        this.status = UserStatus.ACTIVE;
    }
    
    // Getters provide read-only access to maintain encapsulation
    public String getUserId() { return userId; }
    public String getUsername() { return username; }
    public String getHashedPassword() { return hashedPassword; }
    public UserStatus getStatus() { return status; }
    
    // Domain behavior encapsulated within the entity
    public boolean isActive() {
        return status == UserStatus.ACTIVE;
    }
}

The code above demonstrates a domain entity that exists independently of any infrastructure concerns. Notice how the constructor enforces invariants, ensuring that a User object can never exist in an invalid state. This is a fundamental principle of domain-driven design that we explicitly requested in our prompt. The entity contains no references to databases, frameworks, or external libraries. It represents pure business logic.

Now we need to define the ports through which the domain communicates with the outside world. Ports are interfaces that define contracts without specifying implementation details. This allows the domain to remain ignorant of infrastructure concerns while still being able to interact with external systems.

// Port interface - Defines the contract for user persistence
// The domain depends on this abstraction, not on concrete implementations
// This inversion of dependencies is crucial for testability and flexibility

public interface UserRepository {
    
    // Store a new user in the persistence layer
    // The domain doesn't care whether this is a database, file system, or memory
    // Returns the persisted user with any generated fields populated
    User save(User user);
    
    // Retrieve a user by their unique identifier
    // Returns Optional to explicitly handle the case where user doesn't exist
    // This prevents null pointer exceptions and makes the API safer
    Optional<User> findById(String userId);
    
    // Retrieve a user by their username for authentication purposes
    // Username lookups are common during login flows
    Optional<User> findByUsername(String username);
    
    // Check if a username is already taken
    // This supports validation during user registration
    boolean existsByUsername(String username);
}

The UserRepository interface defines what operations the domain needs without specifying how those operations are implemented. This is the essence of the hexagonal architecture's port concept. The domain layer depends on this abstraction, and concrete implementations will be provided by adapters in the infrastructure layer.

Similarly, we need a port for password hashing. The domain needs to hash passwords and verify them, but it should not be coupled to any specific hashing algorithm or library.

// Port interface - Defines the contract for password hashing operations
// Abstracts away the specific hashing algorithm and library
// Allows us to change hashing strategies without modifying domain logic

public interface PasswordHasher {
    
    // Hash a plain text password
    // The implementation might use bcrypt, scrypt, argon2, or any other algorithm
    // The domain doesn't need to know these details
    String hash(String plainTextPassword);
    
    // Verify that a plain text password matches a hashed password
    // Returns true if the password is correct, false otherwise
    // This encapsulates the verification logic within the hashing concern
    boolean verify(String plainTextPassword, String hashedPassword);
}

With our ports defined, we can now implement the core authentication service in the domain layer. This service orchestrates the authentication logic using the port interfaces, remaining completely independent of infrastructure concerns.

// Domain service - Orchestrates authentication business logic
// Uses ports to interact with infrastructure without depending on it
// This is the application core in hexagonal architecture terminology

public class AuthenticationService {
    
    private final UserRepository userRepository;
    private final PasswordHasher passwordHasher;
    
    // Dependencies are injected through the constructor
    // This enables dependency inversion and makes the service testable
    // We can inject mock implementations during testing
    public AuthenticationService(UserRepository userRepository, 
                                 PasswordHasher passwordHasher) {
        this.userRepository = userRepository;
        this.passwordHasher = passwordHasher;
    }
    
    // Authenticate a user with username and password
    // Returns an authentication result that encapsulates success or failure
    // This method contains pure business logic with no infrastructure concerns
    public AuthenticationResult authenticate(String username, String password) {
        
        // Validate input parameters
        if (username == null || username.trim().isEmpty()) {
            return AuthenticationResult.failure("Username cannot be empty");
        }
        if (password == null || password.trim().isEmpty()) {
            return AuthenticationResult.failure("Password cannot be empty");
        }
        
        // Retrieve user from repository using the port interface
        Optional<User> userOptional = userRepository.findByUsername(username);
        
        if (!userOptional.isPresent()) {
            // User not found - return generic error to prevent username enumeration
            return AuthenticationResult.failure("Invalid credentials");
        }
        
        User user = userOptional.get();
        
        // Check if user account is active
        if (!user.isActive()) {
            return AuthenticationResult.failure("Account is not active");
        }
        
        // Verify password using the password hasher port
        boolean passwordValid = passwordHasher.verify(password, user.getHashedPassword());
        
        if (!passwordValid) {
            return AuthenticationResult.failure("Invalid credentials");
        }
        
        // Authentication successful
        return AuthenticationResult.success(user);
    }
    
    // Register a new user
    // Validates business rules and uses ports for persistence and hashing
    public RegistrationResult register(String username, String password) {
        
        // Validate input
        if (username == null || username.trim().isEmpty()) {
            return RegistrationResult.failure("Username cannot be empty");
        }
        if (password == null || password.length() < 8) {
            return RegistrationResult.failure("Password must be at least 8 characters");
        }
        
        // Check if username is already taken - business rule
        if (userRepository.existsByUsername(username)) {
            return RegistrationResult.failure("Username already exists");
        }
        
        // Hash the password using the port interface
        String hashedPassword = passwordHasher.hash(password);
        
        // Create new user entity
        String userId = generateUserId(); // Simplified for example
        User newUser = new User(userId, username, hashedPassword);
        
        // Persist using the repository port
        User savedUser = userRepository.save(newUser);
        
        return RegistrationResult.success(savedUser);
    }
    
    private String generateUserId() {
        // In a real system, this might use UUID or a more sophisticated ID generation
        return java.util.UUID.randomUUID().toString();
    }
}

The AuthenticationService demonstrates how domain logic can be implemented without any infrastructure dependencies. It uses the port interfaces to interact with external concerns, but it has no knowledge of databases, hashing libraries, or frameworks. This separation is what makes the code testable, maintainable, and adaptable to changing requirements.

Notice how the service includes comprehensive validation and error handling. These are business rules that belong in the domain layer. The service returns result objects rather than throwing exceptions for business rule violations, which provides better control flow and makes the API more explicit about possible outcomes.

Now we need to implement the adapters that connect our domain to actual infrastructure. An adapter for the UserRepository might use a relational database, while an adapter for the PasswordHasher might use the bcrypt algorithm.

// Adapter implementation - Connects the domain to PostgreSQL database
// This is infrastructure code that implements the port interface
// It translates between domain concepts and database representations

public class PostgresUserRepository implements UserRepository {
    
    private final DataSource dataSource;
    
    public PostgresUserRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public User save(User user) {
        String sql = "INSERT INTO users (user_id, username, hashed_password, status) " +
                    "VALUES (?, ?, ?, ?) " +
                    "ON CONFLICT (user_id) DO UPDATE SET " +
                    "username = EXCLUDED.username, " +
                    "hashed_password = EXCLUDED.hashed_password, " +
                    "status = EXCLUDED.status";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, user.getUserId());
            stmt.setString(2, user.getUsername());
            stmt.setString(3, user.getHashedPassword());
            stmt.setString(4, user.getStatus().name());
            
            stmt.executeUpdate();
            return user;
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to save user", e);
        }
    }
    
    @Override
    public Optional<User> findById(String userId) {
        String sql = "SELECT user_id, username, hashed_password, status " +
                    "FROM users WHERE user_id = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, userId);
            
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapResultSetToUser(rs));
                }
                return Optional.empty();
            }
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to find user by ID", e);
        }
    }
    
    @Override
    public Optional<User> findByUsername(String username) {
        String sql = "SELECT user_id, username, hashed_password, status " +
                    "FROM users WHERE username = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, username);
            
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapResultSetToUser(rs));
                }
                return Optional.empty();
            }
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to find user by username", e);
        }
    }
    
    @Override
    public boolean existsByUsername(String username) {
        String sql = "SELECT COUNT(*) FROM users WHERE username = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, username);
            
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return rs.getInt(1) > 0;
                }
                return false;
            }
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to check username existence", e);
        }
    }
    
    // Helper method to map database rows to domain entities
    private User mapResultSetToUser(ResultSet rs) throws SQLException {
        return new User(
            rs.getString("user_id"),
            rs.getString("username"),
            rs.getString("hashed_password")
        );
    }
}

The PostgresUserRepository adapter implements the port interface using JDBC to interact with a PostgreSQL database. Notice how all the database-specific code is isolated in this adapter. The domain layer has no knowledge of SQL, JDBC, or PostgreSQL. If we later decide to switch to a different database or persistence mechanism, we can create a new adapter without touching the domain logic.

The adapter handles all the translation between domain concepts and database representations. It manages connections, executes SQL queries, and maps result sets to domain entities. It also handles database-specific exceptions and translates them into domain-appropriate exceptions.

Similarly, we need an adapter for password hashing that implements the PasswordHasher port using a specific hashing algorithm.

// Adapter implementation - Connects the domain to bcrypt hashing library
// This adapter isolates the domain from the specific hashing implementation
// We could easily swap to a different algorithm by creating a new adapter

public class BcryptPasswordHasher implements PasswordHasher {
    
    private final int workFactor;
    
    // Work factor determines the computational cost of hashing
    // Higher values are more secure but slower
    // This is a configuration concern, not a domain concern
    public BcryptPasswordHasher(int workFactor) {
        if (workFactor < 4 || workFactor > 31) {
            throw new IllegalArgumentException("Work factor must be between 4 and 31");
        }
        this.workFactor = workFactor;
    }
    
    @Override
    public String hash(String plainTextPassword) {
        if (plainTextPassword == null) {
            throw new IllegalArgumentException("Password cannot be null");
        }
        
        // Use bcrypt library to hash the password
        // The work factor controls the computational cost
        return BCrypt.hashpw(plainTextPassword, BCrypt.gensalt(workFactor));
    }
    
    @Override
    public boolean verify(String plainTextPassword, String hashedPassword) {
        if (plainTextPassword == null || hashedPassword == null) {
            return false;
        }
        
        try {
            // BCrypt includes the salt in the hash, so we just need both values
            return BCrypt.checkpw(plainTextPassword, hashedPassword);
        } catch (Exception e) {
            // If verification fails for any reason, return false
            // This prevents information leakage through exceptions
            return false;
        }
    }
}

The BcryptPasswordHasher adapter encapsulates all the details of using the bcrypt library. The domain layer simply calls hash and verify methods without knowing anything about bcrypt, work factors, or salt generation. This isolation makes it easy to upgrade to a more secure hashing algorithm in the future without modifying domain logic.

SPECIFYING QUALITY ATTRIBUTES IN PROMPTS

Quality attributes represent the non-functional requirements that determine how well a system performs its intended functions. When prompting an LLM to generate code, we must explicitly specify which quality attributes are most important for our use case. Different quality attributes often require different architectural approaches, and the LLM needs guidance to make appropriate trade-offs.

Performance is a quality attribute that influences many architectural decisions. A high-performance system might employ caching strategies, connection pooling, asynchronous processing, or denormalized data structures. When prompting for performance-oriented code, we should specify the expected load, latency requirements, and throughput targets. For example, a prompt might state: "Design a product catalog service that can handle ten thousand requests per second with ninety-five percentile latency under fifty milliseconds. Implement a multi-level caching strategy using both in-memory and distributed caches. Use connection pooling for database access and implement circuit breakers to prevent cascade failures."

Maintainability focuses on how easily code can be understood, modified, and extended. Maintainable code exhibits high cohesion, low coupling, clear separation of concerns, and comprehensive documentation. A prompt emphasizing maintainability might specify: "Create a payment processing module that prioritizes long-term maintainability. Use the Strategy pattern to support multiple payment providers without modifying existing code. Ensure each class has a single, well-defined responsibility. Include detailed documentation explaining the design decisions and how to add new payment providers. Write code that a developer unfamiliar with the system can understand within thirty minutes of reading."

Testability determines how easily code can be verified through automated tests. Testable code uses dependency injection, avoids global state, separates pure logic from side effects, and provides clear interfaces. When requesting testable code, a prompt should specify: "Implement an order processing service following hexagonal architecture to maximize testability. Define port interfaces for all external dependencies including payment gateway, inventory system, and notification service. Use constructor injection to provide implementations. Ensure the core business logic can be tested without any external systems by using test doubles. Include examples of unit tests that verify business rules in isolation."

Security is a quality attribute that requires careful attention to authentication, authorization, input validation, encryption, and protection against common vulnerabilities. A security-focused prompt might state: "Create a user management API with security as the primary quality attribute. Implement defense in depth with multiple security layers. Validate and sanitize all inputs to prevent injection attacks. Use parameterized queries for database access. Implement proper password hashing with bcrypt and a work factor of twelve. Include rate limiting to prevent brute force attacks. Use secure session management with HTTP-only cookies and CSRF tokens. Document all security measures and potential threat vectors."

Let us examine how quality attributes influence architectural decisions through a concrete example. We will design a notification service with different quality attribute priorities and see how the architecture changes.

First, consider a notification service optimized for high availability and fault tolerance. The architecture must ensure that notifications are never lost, even if individual components fail.

// High availability notification service using event sourcing and retry mechanisms
// This architecture prioritizes fault tolerance and guaranteed delivery
// Events are persisted before processing to ensure no messages are lost

public class NotificationService {
    
    private final EventStore eventStore;
    private final NotificationDispatcher dispatcher;
    private final RetryPolicy retryPolicy;
    
    public NotificationService(EventStore eventStore,
                               NotificationDispatcher dispatcher,
                               RetryPolicy retryPolicy) {
        this.eventStore = eventStore;
        this.dispatcher = dispatcher;
        this.retryPolicy = retryPolicy;
    }
    
    // Send notification with guaranteed delivery semantics
    // Event is persisted before processing to ensure durability
    public void sendNotification(Notification notification) {
        
        // Create an event representing this notification request
        NotificationEvent event = new NotificationEvent(
            generateEventId(),
            notification,
            Instant.now(),
            EventStatus.PENDING
        );
        
        // Persist the event before attempting to send
        // This ensures we can retry even if the process crashes
        eventStore.append(event);
        
        // Attempt to dispatch the notification with retry logic
        dispatchWithRetry(event);
    }
    
    // Dispatch notification with exponential backoff retry
    private void dispatchWithRetry(NotificationEvent event) {
        
        int attempt = 0;
        boolean success = false;
        
        while (attempt < retryPolicy.getMaxAttempts() && !success) {
            try {
                // Attempt to send the notification
                dispatcher.dispatch(event.getNotification());
                
                // Mark event as completed in the event store
                eventStore.markCompleted(event.getEventId());
                success = true;
                
            } catch (DispatchException e) {
                attempt++;
                
                if (attempt < retryPolicy.getMaxAttempts()) {
                    // Calculate backoff delay using exponential strategy
                    long delayMillis = retryPolicy.calculateBackoff(attempt);
                    
                    // Update event with retry information
                    eventStore.recordRetry(event.getEventId(), attempt, e.getMessage());
                    
                    // Wait before retrying
                    sleep(delayMillis);
                } else {
                    // Max retries exceeded - mark as failed for manual intervention
                    eventStore.markFailed(event.getEventId(), e.getMessage());
                }
            }
        }
    }
    
    // Background process to retry failed notifications
    // This ensures eventual delivery even after temporary failures
    public void processFailedNotifications() {
        
        List<NotificationEvent> failedEvents = eventStore.findFailedEvents();
        
        for (NotificationEvent event : failedEvents) {
            // Check if enough time has passed for retry
            if (shouldRetryEvent(event)) {
                dispatchWithRetry(event);
            }
        }
    }
    
    private boolean shouldRetryEvent(NotificationEvent event) {
        // Implement logic to determine if event should be retried
        // Consider factors like time since last attempt, number of retries, etc.
        return true; // Simplified for example
    }
    
    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    private String generateEventId() {
        return java.util.UUID.randomUUID().toString();
    }
}

This implementation prioritizes high availability and fault tolerance by persisting notification events before attempting to send them. The event store provides durability, ensuring that no notifications are lost even if the system crashes. The retry mechanism with exponential backoff handles transient failures gracefully. A background process can recover from extended outages by reprocessing failed events.

Now consider the same notification service optimized for low latency and high throughput. The architecture changes significantly to meet these different quality attributes.

// High performance notification service using asynchronous processing
// This architecture prioritizes low latency and high throughput
// Notifications are processed asynchronously without blocking the caller

public class HighPerformanceNotificationService {
    
    private final ExecutorService executorService;
    private final NotificationQueue queue;
    private final NotificationDispatcher dispatcher;
    private final MetricsCollector metrics;
    
    public HighPerformanceNotificationService(int threadPoolSize,
                                              NotificationQueue queue,
                                              NotificationDispatcher dispatcher,
                                              MetricsCollector metrics) {
        // Use a bounded thread pool to control resource usage
        this.executorService = Executors.newFixedThreadPool(threadPoolSize);
        this.queue = queue;
        this.dispatcher = dispatcher;
        this.metrics = metrics;
        
        // Start worker threads to process notifications
        startWorkers(threadPoolSize);
    }
    
    // Send notification asynchronously - returns immediately
    // Caller is not blocked waiting for notification to be sent
    public CompletableFuture<Void> sendNotificationAsync(Notification notification) {
        
        long startTime = System.nanoTime();
        
        // Enqueue notification for asynchronous processing
        // This operation is very fast, typically just a queue insertion
        queue.enqueue(notification);
        
        // Record metrics for monitoring
        long enqueueDuration = System.nanoTime() - startTime;
        metrics.recordEnqueueLatency(enqueueDuration);
        
        // Return a future that completes when notification is sent
        // Caller can choose to wait or continue processing
        return CompletableFuture.runAsync(() -> {
            // This will be completed by the worker thread
        }, executorService);
    }
    
    // Start worker threads that continuously process notifications
    private void startWorkers(int workerCount) {
        for (int i = 0; i < workerCount; i++) {
            executorService.submit(new NotificationWorker());
        }
    }
    
    // Worker thread that processes notifications from the queue
    private class NotificationWorker implements Runnable {
        
        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    // Block waiting for next notification
                    // This is efficient as thread sleeps when queue is empty
                    Notification notification = queue.dequeue();
                    
                    long startTime = System.nanoTime();
                    
                    // Dispatch the notification
                    dispatcher.dispatch(notification);
                    
                    // Record successful processing metrics
                    long processingDuration = System.nanoTime() - startTime;
                    metrics.recordProcessingLatency(processingDuration);
                    metrics.incrementSuccessCount();
                    
                } catch (InterruptedException e) {
                    // Thread was interrupted - exit gracefully
                    Thread.currentThread().interrupt();
                    break;
                } catch (Exception e) {
                    // Log error but continue processing other notifications
                    metrics.incrementErrorCount();
                }
            }
        }
    }
    
    // Gracefully shutdown the service
    public void shutdown() {
        executorService.shutdown();
        try {
            if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
                executorService.shutdownNow();
            }
        } catch (InterruptedException e) {
            executorService.shutdownNow();
        }
    }
}

The high-performance version uses asynchronous processing with a thread pool and message queue. Callers are not blocked waiting for notifications to be sent, which dramatically improves throughput. Worker threads process notifications concurrently, maximizing resource utilization. The service collects metrics to monitor performance and identify bottlenecks.

Notice how the same functional requirement, sending notifications, results in completely different architectures depending on which quality attributes we prioritize. The first version optimizes for reliability and fault tolerance at the cost of some latency. The second version optimizes for throughput and low latency but requires more complex error handling to achieve the same level of reliability.

When prompting an LLM, we must explicitly state which quality attributes are most important and what trade-offs are acceptable. Without this guidance, the LLM will generate code that may not align with our actual requirements.

PATTERN INTEGRATION THROUGH PROMPTING

Architectural and design patterns provide proven solutions to recurring problems. When prompting an LLM to generate code, we can request specific patterns and explain how they should be applied. However, we must go beyond simply naming the pattern. We need to explain the context, the problem it solves, and how it should be implemented in our specific situation.

The Repository pattern abstracts data access logic, providing a collection-like interface for accessing domain objects. When requesting this pattern, we should specify not just the pattern name but also how it should handle transactions, caching, and error conditions. A well-crafted prompt might state: "Implement the Repository pattern for the Order entity. The repository should provide methods for finding orders by ID, customer ID, and date range. Implement the Unit of Work pattern to manage transactions across multiple repository operations. Include an in-memory cache with a time-to-live of five minutes to reduce database load. Handle concurrent modifications using optimistic locking with version numbers."

The Strategy pattern enables selecting algorithms at runtime by encapsulating them behind a common interface. When prompting for this pattern, we should explain the variation points and how strategies should be selected. For example: "Implement a pricing calculator using the Strategy pattern. Support multiple pricing strategies including standard pricing, volume discounts, seasonal promotions, and customer-specific pricing. Each strategy should implement a common interface with a calculate method. Use a factory to select the appropriate strategy based on customer type and current date. Ensure strategies are stateless and thread-safe."

The Observer pattern enables loose coupling between objects by allowing subjects to notify observers of state changes without knowing their concrete types. A prompt requesting this pattern should specify the events, the notification mechanism, and how observers are registered. Consider: "Implement the Observer pattern for order status changes. When an order status changes, notify all registered observers including inventory management, shipping service, and customer notification service. Use asynchronous notification to prevent slow observers from blocking the order processing. Implement error isolation so that failures in one observer do not affect others. Provide a mechanism for observers to specify which order statuses they are interested in."

The Decorator pattern allows adding responsibilities to objects dynamically without modifying their code. When requesting this pattern, we should explain what aspects can be decorated and in what order decorators should be applied. For instance: "Implement a logging decorator for the payment service using the Decorator pattern. The decorator should log method calls, parameters, return values, and execution time. Support multiple decoration layers including logging, caching, retry logic, and circuit breaking. Ensure decorators can be composed in any order. Each decorator should implement the same interface as the underlying service to maintain transparency."

Let us explore a comprehensive example that combines multiple patterns to solve a complex problem. We will build an order processing system that uses the Strategy pattern for pricing, the Observer pattern for notifications, the Repository pattern for persistence, and the Decorator pattern for cross-cutting concerns.

// Order entity - Domain model representing a customer order
// This is a rich domain model that contains business logic
// It follows the principle of keeping behavior close to data

public class Order {
    
    private final String orderId;
    private final String customerId;
    private final List<OrderLine> orderLines;
    private OrderStatus status;
    private BigDecimal totalAmount;
    private final Instant createdAt;
    private Instant lastModifiedAt;
    private int version; // For optimistic locking
    
    public Order(String orderId, String customerId, List<OrderLine> orderLines) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.orderLines = new ArrayList<>(orderLines);
        this.status = OrderStatus.PENDING;
        this.createdAt = Instant.now();
        this.lastModifiedAt = this.createdAt;
        this.version = 0;
        this.totalAmount = BigDecimal.ZERO;
    }
    
    // Calculate total using a pricing strategy
    // This allows different pricing rules without modifying the Order class
    public void calculateTotal(PricingStrategy pricingStrategy) {
        this.totalAmount = pricingStrategy.calculateTotal(this);
        this.lastModifiedAt = Instant.now();
    }
    
    // Change order status and notify observers
    // This encapsulates the business rule for status transitions
    public void changeStatus(OrderStatus newStatus, List<OrderObserver> observers) {
        
        // Validate status transition
        if (!isValidTransition(this.status, newStatus)) {
            throw new InvalidStatusTransitionException(
                "Cannot transition from " + this.status + " to " + newStatus
            );
        }
        
        OrderStatus oldStatus = this.status;
        this.status = newStatus;
        this.lastModifiedAt = Instant.now();
        this.version++;
        
        // Notify all observers of the status change
        notifyObservers(observers, oldStatus, newStatus);
    }
    
    // Validate that a status transition is allowed
    private boolean isValidTransition(OrderStatus from, OrderStatus to) {
        // Define valid transitions based on business rules
        switch (from) {
            case PENDING:
                return to == OrderStatus.CONFIRMED || to == OrderStatus.CANCELLED;
            case CONFIRMED:
                return to == OrderStatus.SHIPPED || to == OrderStatus.CANCELLED;
            case SHIPPED:
                return to == OrderStatus.DELIVERED;
            case DELIVERED:
                return false; // Terminal state
            case CANCELLED:
                return false; // Terminal state
            default:
                return false;
        }
    }
    
    // Notify observers asynchronously to prevent blocking
    private void notifyObservers(List<OrderObserver> observers, 
                                 OrderStatus oldStatus, 
                                 OrderStatus newStatus) {
        for (OrderObserver observer : observers) {
            try {
                // Each observer is notified in a separate thread
                // This prevents slow observers from blocking order processing
                CompletableFuture.runAsync(() -> {
                    observer.onOrderStatusChanged(this, oldStatus, newStatus);
                });
            } catch (Exception e) {
                // Log error but continue notifying other observers
                // This implements error isolation
            }
        }
    }
    
    // Getters and other methods omitted for brevity
    public String getOrderId() { return orderId; }
    public String getCustomerId() { return customerId; }
    public List<OrderLine> getOrderLines() { return new ArrayList<>(orderLines); }
    public OrderStatus getStatus() { return status; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public int getVersion() { return version; }
}

The Order entity demonstrates how domain logic can be encapsulated within the entity itself. The calculateTotal method uses the Strategy pattern by accepting a PricingStrategy parameter. The changeStatus method implements the Observer pattern by notifying registered observers. The version field supports optimistic locking in the Repository pattern.

Now let us implement the Strategy pattern for pricing calculations. Different customers or situations may require different pricing rules, and the Strategy pattern allows us to vary these rules without modifying the Order class.

// Strategy interface for pricing calculations
// Different implementations can provide different pricing algorithms
// This enables runtime selection of pricing rules

public interface PricingStrategy {
    
    // Calculate the total price for an order
    // The strategy has access to all order details to make pricing decisions
    BigDecimal calculateTotal(Order order);
    
    // Get a description of this pricing strategy
    // Useful for logging and debugging
    String getDescription();
}


// Standard pricing strategy - Base implementation
// Simply sums the price of all order lines

public class StandardPricingStrategy implements PricingStrategy {
    
    @Override
    public BigDecimal calculateTotal(Order order) {
        BigDecimal total = BigDecimal.ZERO;
        
        for (OrderLine line : order.getOrderLines()) {
            BigDecimal lineTotal = line.getUnitPrice()
                .multiply(BigDecimal.valueOf(line.getQuantity()));
            total = total.add(lineTotal);
        }
        
        return total;
    }
    
    @Override
    public String getDescription() {
        return "Standard pricing with no discounts";
    }
}


// Volume discount strategy - Applies discounts for large orders
// This demonstrates how business rules can be encapsulated in strategies

public class VolumeDiscountPricingStrategy implements PricingStrategy {
    
    private final BigDecimal discountThreshold;
    private final BigDecimal discountPercentage;
    
    public VolumeDiscountPricingStrategy(BigDecimal discountThreshold, 
                                         BigDecimal discountPercentage) {
        this.discountThreshold = discountThreshold;
        this.discountPercentage = discountPercentage;
    }
    
    @Override
    public BigDecimal calculateTotal(Order order) {
        // First calculate standard total
        BigDecimal standardTotal = BigDecimal.ZERO;
        
        for (OrderLine line : order.getOrderLines()) {
            BigDecimal lineTotal = line.getUnitPrice()
                .multiply(BigDecimal.valueOf(line.getQuantity()));
            standardTotal = standardTotal.add(lineTotal);
        }
        
        // Apply volume discount if threshold is met
        if (standardTotal.compareTo(discountThreshold) >= 0) {
            BigDecimal discount = standardTotal
                .multiply(discountPercentage)
                .divide(BigDecimal.valueOf(100));
            return standardTotal.subtract(discount);
        }
        
        return standardTotal;
    }
    
    @Override
    public String getDescription() {
        return String.format("Volume discount: %s%% off orders over %s",
            discountPercentage, discountThreshold);
    }
}

The pricing strategies demonstrate how the Strategy pattern encapsulates varying algorithms. Each strategy implements the same interface but provides different pricing logic. New strategies can be added without modifying existing code, adhering to the Open-Closed Principle.

The Observer pattern allows different parts of the system to react to order status changes without tight coupling. Let us implement observers for inventory management and customer notifications.

// Observer interface for order status changes
// Observers are notified when order status changes
// This enables loose coupling between order processing and other concerns

public interface OrderObserver {
    
    // Called when an order's status changes
    // Observers can react to specific status transitions
    void onOrderStatusChanged(Order order, OrderStatus oldStatus, OrderStatus newStatus);
    
    // Determine if this observer is interested in a specific status change
    // This allows filtering notifications to reduce unnecessary processing
    boolean isInterestedIn(OrderStatus oldStatus, OrderStatus newStatus);
}


// Inventory observer - Updates inventory when orders are confirmed or cancelled
// This demonstrates how observers can encapsulate specific business logic

public class InventoryObserver implements OrderObserver {
    
    private final InventoryService inventoryService;
    
    public InventoryObserver(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }
    
    @Override
    public void onOrderStatusChanged(Order order, 
                                     OrderStatus oldStatus, 
                                     OrderStatus newStatus) {
        
        // Only process if we're interested in this transition
        if (!isInterestedIn(oldStatus, newStatus)) {
            return;
        }
        
        if (newStatus == OrderStatus.CONFIRMED) {
            // Reserve inventory when order is confirmed
            for (OrderLine line : order.getOrderLines()) {
                inventoryService.reserveInventory(
                    line.getProductId(),
                    line.getQuantity(),
                    order.getOrderId()
                );
            }
        } else if (newStatus == OrderStatus.CANCELLED) {
            // Release inventory when order is cancelled
            for (OrderLine line : order.getOrderLines()) {
                inventoryService.releaseInventory(
                    line.getProductId(),
                    line.getQuantity(),
                    order.getOrderId()
                );
            }
        }
    }
    
    @Override
    public boolean isInterestedIn(OrderStatus oldStatus, OrderStatus newStatus) {
        // Only interested in confirmations and cancellations
        return newStatus == OrderStatus.CONFIRMED || newStatus == OrderStatus.CANCELLED;
    }
}

The observers demonstrate how different concerns can react to order events independently. The InventoryObserver handles inventory management without the Order class needing to know about inventory logic. This separation of concerns makes the system more maintainable and testable.

Finally, let us implement the Decorator pattern to add cross-cutting concerns like logging and caching to our order repository without modifying its core logic.

// Base repository interface
// Decorators will implement this same interface

public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(String orderId);
    List<Order> findByCustomerId(String customerId);
}


// Logging decorator - Adds logging to repository operations
// This demonstrates how decorators can add behavior transparently

public class LoggingOrderRepositoryDecorator implements OrderRepository {
    
    private final OrderRepository delegate;
    private final Logger logger;
    
    public LoggingOrderRepositoryDecorator(OrderRepository delegate, Logger logger) {
        this.delegate = delegate;
        this.logger = logger;
    }
    
    @Override
    public Order save(Order order) {
        logger.info("Saving order: " + order.getOrderId());
        long startTime = System.nanoTime();
        
        try {
            Order savedOrder = delegate.save(order);
            long duration = System.nanoTime() - startTime;
            logger.info("Order saved successfully in " + duration + " ns");
            return savedOrder;
        } catch (Exception e) {
            logger.error("Failed to save order: " + order.getOrderId(), e);
            throw e;
        }
    }
    
    @Override
    public Optional<Order> findById(String orderId) {
        logger.info("Finding order by ID: " + orderId);
        long startTime = System.nanoTime();
        
        Optional<Order> result = delegate.findById(orderId);
        long duration = System.nanoTime() - startTime;
        
        if (result.isPresent()) {
            logger.info("Order found in " + duration + " ns");
        } else {
            logger.info("Order not found in " + duration + " ns");
        }
        
        return result;
    }
    
    @Override
    public List<Order> findByCustomerId(String customerId) {
        logger.info("Finding orders for customer: " + customerId);
        long startTime = System.nanoTime();
        
        List<Order> results = delegate.findByCustomerId(customerId);
        long duration = System.nanoTime() - startTime;
        
        logger.info("Found " + results.size() + " orders in " + duration + " ns");
        return results;
    }
}

The decorator implements the same interface as the repository it decorates, allowing decorators to be stacked transparently. The logging decorator adds logging behavior without modifying the underlying repository implementation. We could add additional decorators for caching, retry logic, or circuit breaking, composing them in any order.

DOCUMENTATION REQUIREMENTS IN PROMPTS

Documentation is often the difference between code that can be maintained and code that must be rewritten. When prompting an LLM to generate code, we must explicitly request comprehensive documentation that explains not just what the code does, but why it does it, what alternatives were considered, and what constraints influenced the design.

Effective documentation operates at multiple levels. At the highest level, architectural documentation explains the overall structure of the system, the major components, and how they interact. It describes the architectural patterns used and the rationale for choosing them. It identifies the key quality attributes and explains how the architecture achieves them. This level of documentation helps developers understand the big picture before diving into implementation details.

At the component level, documentation explains the purpose of each module, its responsibilities, and its dependencies. It describes the public interface and how other components should interact with it. It documents any assumptions, preconditions, and postconditions. This level of documentation helps developers understand how to use a component correctly without needing to read its implementation.

At the code level, documentation explains complex algorithms, non-obvious design decisions, and important business rules. It clarifies the intent behind code that might otherwise be confusing. It warns about potential pitfalls and explains workarounds for known issues. This level of documentation helps developers modify code safely without introducing bugs.

When prompting an LLM, we should request documentation at all these levels. A comprehensive prompt might state: "Generate a cache implementation with complete documentation. Include architectural documentation explaining the caching strategy, eviction policy, and concurrency model. Document each public method with JavaDoc comments explaining parameters, return values, exceptions, and usage examples. Include inline comments for complex algorithms such as the LRU eviction logic. Document thread safety guarantees and any synchronization mechanisms used. Explain why specific design decisions were made, such as the choice of data structures or the approach to handling cache misses."

Let us examine a well-documented cache implementation that demonstrates these principles.

/**
 * Thread-safe LRU (Least Recently Used) cache implementation.
 * 
 * This cache provides O(1) time complexity for get and put operations
 * by combining a HashMap for fast lookups with a doubly-linked list
 * for tracking access order. When the cache reaches its capacity,
 * the least recently used entry is evicted to make room for new entries.
 * 
 * Thread Safety:
 * All public methods are synchronized to ensure thread safety. While this
 * provides strong consistency guarantees, it may limit concurrency under
 * high load. For applications requiring higher throughput, consider using
 * a concurrent cache implementation with finer-grained locking.
 * 
 * Memory Considerations:
 * The cache maintains references to all cached objects. Ensure that the
 * maximum size is set appropriately to avoid excessive memory usage.
 * Each cache entry has overhead for the linked list nodes and hash map
 * entries, approximately 64 bytes per entry on most JVMs.
 * 
 * Usage Example:
 * <pre>
 * LRUCache<String, User> userCache = new LRUCache<>(1000);
 * userCache.put("user123", user);
 * Optional<User> cachedUser = userCache.get("user123");
 * </pre>
 * 
 * @param <K> the type of keys maintained by this cache
 * @param <V> the type of cached values
 */
public class LRUCache<K, V> {
    
    // Internal node structure for the doubly-linked list
    // This is used to maintain access order efficiently
    private static class Node<K, V> {
        K key;
        V value;
        Node<K, V> prev;
        Node<K, V> next;
        
        Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
    
    // HashMap provides O(1) lookup by key
    // Maps keys to their corresponding nodes in the linked list
    private final Map<K, Node<K, V>> cache;
    
    // Maximum number of entries the cache can hold
    // When this limit is reached, the LRU entry is evicted
    private final int capacity;
    
    // Dummy head and tail nodes simplify linked list operations
    // They eliminate the need for null checks when adding/removing nodes
    private final Node<K, V> head;
    private final Node<K, V> tail;
    
    /**
     * Constructs an LRU cache with the specified capacity.
     * 
     * @param capacity the maximum number of entries the cache can hold
     * @throws IllegalArgumentException if capacity is less than 1
     */
    public LRUCache(int capacity) {
        if (capacity < 1) {
            throw new IllegalArgumentException("Capacity must be at least 1");
        }
        
        this.capacity = capacity;
        this.cache = new HashMap<>();
        
        // Initialize dummy head and tail nodes
        // These simplify insertion and removal operations
        this.head = new Node<>(null, null);
        this.tail = new Node<>(null, null);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }
    
    /**
     * Retrieves a value from the cache.
     * 
     * If the key exists in the cache, this operation marks it as recently
     * used by moving it to the front of the access order list. This ensures
     * that frequently accessed items are less likely to be evicted.
     * 
     * Time Complexity: O(1)
     * 
     * @param key the key whose associated value is to be returned
     * @return an Optional containing the value if present, or empty if not found
     * @throws NullPointerException if the key is null
     */
    public synchronized Optional<V> get(K key) {
        if (key == null) {
            throw new NullPointerException("Key cannot be null");
        }
        
        Node<K, V> node = cache.get(key);
        
        if (node == null) {
            return Optional.empty();
        }
        
        // Move the accessed node to the front (most recently used position)
        // This is the key operation that maintains LRU ordering
        moveToFront(node);
        
        return Optional.of(node.value);
    }
    
    /**
     * Adds or updates a key-value pair in the cache.
     * 
     * If the key already exists, its value is updated and it is marked as
     * recently used. If the key is new and the cache is at capacity, the
     * least recently used entry is evicted before adding the new entry.
     * 
     * Time Complexity: O(1)
     * 
     * @param key the key with which the specified value is to be associated
     * @param value the value to be associated with the specified key
     * @throws NullPointerException if the key or value is null
     */
    public synchronized void put(K key, V value) {
        if (key == null || value == null) {
            throw new NullPointerException("Key and value cannot be null");
        }
        
        Node<K, V> node = cache.get(key);
        
        if (node != null) {
            // Key already exists - update value and move to front
            node.value = value;
            moveToFront(node);
        } else {
            // New key - create new node
            Node<K, V> newNode = new Node<>(key, value);
            cache.put(key, newNode);
            addToFront(newNode);
            
            // Check if we exceeded capacity
            if (cache.size() > capacity) {
                // Evict the least recently used entry (at the tail)
                evictLRU();
            }
        }
    }
    
    /**
     * Moves a node to the front of the linked list.
     * 
     * This operation is called whenever a node is accessed, marking it as
     * the most recently used. The node is first removed from its current
     * position, then added to the front of the list.
     * 
     * @param node the node to move to the front
     */
    private void moveToFront(Node<K, V> node) {
        removeNode(node);
        addToFront(node);
    }
    
    /**
     * Adds a node to the front of the linked list.
     * 
     * The front of the list represents the most recently used position.
     * This operation inserts the node immediately after the dummy head.
     * 
     * @param node the node to add to the front
     */
    private void addToFront(Node<K, V> node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
    
    /**
     * Removes a node from the linked list.
     * 
     * This operation updates the prev and next pointers of adjacent nodes
     * to bypass the removed node. The node itself is not modified, allowing
     * it to be reinserted elsewhere if needed.
     * 
     * @param node the node to remove from the list
     */
    private void removeNode(Node<K, V> node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    /**
     * Evicts the least recently used entry from the cache.
     * 
     * The LRU entry is always at the tail of the linked list (just before
     * the dummy tail node). This method removes it from both the linked list
     * and the hash map, freeing up space for new entries.
     */
    private void evictLRU() {
        Node<K, V> lruNode = tail.prev;
        removeNode(lruNode);
        cache.remove(lruNode.key);
    }
    
    /**
     * Returns the current number of entries in the cache.
     * 
     * @return the number of key-value pairs currently in the cache
     */
    public synchronized int size() {
        return cache.size();
    }
    
    /**
     * Removes all entries from the cache.
     * 
     * After this operation, the cache will be empty and size() will return 0.
     */
    public synchronized void clear() {
        cache.clear();
        head.next = tail;
        tail.prev = head;
    }
}

This cache implementation demonstrates comprehensive documentation at multiple levels. The class-level JavaDoc explains the overall design, the data structures used, thread safety guarantees, memory considerations, and provides usage examples. Each method is documented with its purpose, parameters, return values, time complexity, and any exceptions it might throw. Inline comments explain non-obvious implementation details such as why dummy nodes are used and how the LRU ordering is maintained.

The documentation also explains trade-offs and alternatives. It notes that while synchronization provides strong consistency, it may limit concurrency, and suggests considering concurrent implementations for high-throughput scenarios. This kind of documentation helps developers make informed decisions about whether this implementation is appropriate for their use case.

LAYERED ARCHITECTURE THROUGH PROMPTING

Layered architecture organizes code into horizontal layers, each with a specific responsibility. The most common layers are presentation, application, domain, and infrastructure. Each layer depends only on the layers below it, creating a clear separation of concerns that improves maintainability and testability.

When prompting an LLM to generate layered architecture, we must clearly specify the responsibilities of each layer and the dependencies between them. We should explain what belongs in each layer and what should be avoided. A comprehensive prompt might state: "Create a product catalog service using a four-layer architecture. The domain layer contains product entities and business rules with no dependencies on infrastructure. The application layer contains use cases that orchestrate domain objects and coordinate with infrastructure through interfaces. The infrastructure layer contains implementations for database access, external API clients, and other technical concerns. The presentation layer contains REST API controllers that handle HTTP requests and responses. Ensure that dependencies flow downward only, with upper layers depending on abstractions defined in lower layers."

Let us build a complete example of a layered architecture for a product catalog service, starting with the domain layer.

// DOMAIN LAYER
// Contains core business entities and business rules
// Has no dependencies on infrastructure or frameworks
// This is the heart of the application where business logic lives

/**
 * Product entity representing a product in the catalog.
 * 
 * This is a rich domain model that contains both data and behavior.
 * Business rules are enforced through methods rather than allowing
 * direct manipulation of state. This ensures that the product can
 * never exist in an invalid state.
 */
public class Product {
    
    private final String productId;
    private String name;
    private String description;
    private Money price;
    private int stockQuantity;
    private ProductStatus status;
    
    public Product(String productId, String name, String description, 
                  Money price, int stockQuantity) {
        // Validate invariants at construction time
        validateProductId(productId);
        validateName(name);
        validatePrice(price);
        validateStockQuantity(stockQuantity);
        
        this.productId = productId;
        this.name = name;
        this.description = description;
        this.price = price;
        this.stockQuantity = stockQuantity;
        this.status = ProductStatus.ACTIVE;
    }
    
    /**
     * Updates the product price.
     * 
     * This method encapsulates the business rule that prices cannot be negative.
     * It also ensures that price changes are validated before being applied.
     * 
     * @param newPrice the new price for the product
     * @throws IllegalArgumentException if the price is invalid
     */
    public void updatePrice(Money newPrice) {
        validatePrice(newPrice);
        this.price = newPrice;
    }
    
    /**
     * Reserves stock for an order.
     * 
     * This method implements the business rule that stock cannot go negative.
     * It returns a result object indicating success or failure rather than
     * throwing an exception, which provides better control flow.
     * 
     * @param quantity the quantity to reserve
     * @return a result indicating whether the reservation succeeded
     */
    public ReservationResult reserveStock(int quantity) {
        if (quantity <= 0) {
            return ReservationResult.failure("Quantity must be positive");
        }
        
        if (quantity > stockQuantity) {
            return ReservationResult.failure("Insufficient stock available");
        }
        
        if (status != ProductStatus.ACTIVE) {
            return ReservationResult.failure("Product is not active");
        }
        
        stockQuantity -= quantity;
        return ReservationResult.success();
    }
    
    /**
     * Releases previously reserved stock.
     * 
     * This might be called when an order is cancelled. It implements
     * the business rule that stock quantity cannot exceed a maximum value.
     * 
     * @param quantity the quantity to release
     */
    public void releaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        
        stockQuantity += quantity;
    }
    
    // Validation methods enforce business rules
    private void validateProductId(String productId) {
        if (productId == null || productId.trim().isEmpty()) {
            throw new IllegalArgumentException("Product ID cannot be null or empty");
        }
    }
    
    private void validateName(String name) {
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("Product name cannot be null or empty");
        }
        if (name.length() > 200) {
            throw new IllegalArgumentException("Product name cannot exceed 200 characters");
        }
    }
    
    private void validatePrice(Money price) {
        if (price == null) {
            throw new IllegalArgumentException("Price cannot be null");
        }
        if (price.isNegative()) {
            throw new IllegalArgumentException("Price cannot be negative");
        }
    }
    
    private void validateStockQuantity(int quantity) {
        if (quantity < 0) {
            throw new IllegalArgumentException("Stock quantity cannot be negative");
        }
    }
    
    // Getters provide read-only access
    public String getProductId() { return productId; }
    public String getName() { return name; }
    public String getDescription() { return description; }
    public Money getPrice() { return price; }
    public int getStockQuantity() { return stockQuantity; }
    public ProductStatus getStatus() { return status; }
}

The domain layer contains pure business logic with no infrastructure dependencies. The Product entity enforces business rules through its methods, ensuring that it can never exist in an invalid state. Notice how the reserveStock method returns a result object rather than throwing an exception for business rule violations. This makes the API more explicit about possible outcomes and provides better control flow.

Now let us define the application layer, which contains use cases that orchestrate domain objects and coordinate with infrastructure.

// APPLICATION LAYER
// Contains use cases that orchestrate domain objects
// Depends on domain layer and infrastructure abstractions
// Coordinates transactions and cross-cutting concerns

/**
 * Application service for product catalog operations.
 * 
 * This service implements use cases by orchestrating domain objects
 * and coordinating with infrastructure through port interfaces.
 * It manages transactions and handles cross-cutting concerns like
 * logging and error handling.
 */
public class ProductCatalogService {
    
    private final ProductRepository productRepository;
    private final InventoryEventPublisher eventPublisher;
    private final TransactionManager transactionManager;
    
    public ProductCatalogService(ProductRepository productRepository,
                                InventoryEventPublisher eventPublisher,
                                TransactionManager transactionManager) {
        this.productRepository = productRepository;
        this.eventPublisher = eventPublisher;
        this.transactionManager = transactionManager;
    }
    
    /**
     * Creates a new product in the catalog.
     * 
     * This use case validates the product data, creates the domain entity,
     * persists it through the repository, and publishes an event. All
     * operations are performed within a transaction to ensure consistency.
     * 
     * @param request the product creation request
     * @return a result containing the created product or an error
     */
    public ProductCreationResult createProduct(CreateProductRequest request) {
        
        return transactionManager.executeInTransaction(() -> {
            
            // Validate that product ID is unique
            if (productRepository.existsById(request.getProductId())) {
                return ProductCreationResult.failure("Product ID already exists");
            }
            
            // Create domain entity
            // The entity constructor validates business rules
            Product product;
            try {
                product = new Product(
                    request.getProductId(),
                    request.getName(),
                    request.getDescription(),
                    request.getPrice(),
                    request.getStockQuantity()
                );
            } catch (IllegalArgumentException e) {
                return ProductCreationResult.failure(e.getMessage());
            }
            
            // Persist through repository
            Product savedProduct = productRepository.save(product);
            
            // Publish domain event
            eventPublisher.publishProductCreated(savedProduct);
            
            return ProductCreationResult.success(savedProduct);
        });
    }
    
    /**
     * Updates the price of an existing product.
     * 
     * This use case retrieves the product, updates its price using the
     * domain method, and persists the change. It demonstrates how
     * application services coordinate domain objects and infrastructure.
     * 
     * @param productId the ID of the product to update
     * @param newPrice the new price
     * @return a result indicating success or failure
     */
    public PriceUpdateResult updatePrice(String productId, Money newPrice) {
        
        return transactionManager.executeInTransaction(() -> {
            
            // Retrieve product from repository
            Optional<Product> productOptional = productRepository.findById(productId);
            
            if (!productOptional.isPresent()) {
                return PriceUpdateResult.failure("Product not found");
            }
            
            Product product = productOptional.get();
            
            // Update price using domain method
            // This ensures business rules are enforced
            try {
                product.updatePrice(newPrice);
            } catch (IllegalArgumentException e) {
                return PriceUpdateResult.failure(e.getMessage());
            }
            
            // Persist the updated product
            productRepository.save(product);
            
            // Publish domain event
            eventPublisher.publishPriceChanged(product, newPrice);
            
            return PriceUpdateResult.success();
        });
    }
    
    /**
     * Reserves stock for an order.
     * 
     * This use case demonstrates how application services handle
     * business operations that span multiple concerns. It coordinates
     * the domain logic with persistence and event publishing.
     * 
     * @param productId the ID of the product
     * @param quantity the quantity to reserve
     * @return a result indicating whether the reservation succeeded
     */
    public StockReservationResult reserveStock(String productId, int quantity) {
        
        return transactionManager.executeInTransaction(() -> {
            
            Optional<Product> productOptional = productRepository.findById(productId);
            
            if (!productOptional.isPresent()) {
                return StockReservationResult.failure("Product not found");
            }
            
            Product product = productOptional.get();
            
            // Attempt to reserve stock using domain method
            ReservationResult reservationResult = product.reserveStock(quantity);
            
            if (!reservationResult.isSuccess()) {
                return StockReservationResult.failure(reservationResult.getMessage());
            }
            
            // Persist the updated stock quantity
            productRepository.save(product);
            
            // Publish domain event
            eventPublisher.publishStockReserved(product, quantity);
            
            return StockReservationResult.success();
        });
    }
}

The application layer orchestrates domain objects and coordinates with infrastructure through port interfaces. Notice how each use case is wrapped in a transaction to ensure consistency. The service delegates business logic to domain entities rather than implementing it directly. This keeps the application layer thin and focused on coordination rather than business rules.

The infrastructure layer provides concrete implementations of the port interfaces defined by the domain and application layers.

// INFRASTRUCTURE LAYER
// Contains implementations of infrastructure concerns
// Depends on domain and application layers through their abstractions
// Implements port interfaces defined by inner layers

/**
 * PostgreSQL implementation of the ProductRepository port.
 * 
 * This adapter translates between domain entities and database
 * representations. It handles all database-specific concerns including
 * SQL queries, connection management, and result set mapping.
 */
public class PostgresProductRepository implements ProductRepository {
    
    private final DataSource dataSource;
    
    public PostgresProductRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    
    @Override
    public Product save(Product product) {
        String sql = "INSERT INTO products " +
                    "(product_id, name, description, price_amount, price_currency, " +
                    "stock_quantity, status) " +
                    "VALUES (?, ?, ?, ?, ?, ?, ?) " +
                    "ON CONFLICT (product_id) DO UPDATE SET " +
                    "name = EXCLUDED.name, " +
                    "description = EXCLUDED.description, " +
                    "price_amount = EXCLUDED.price_amount, " +
                    "price_currency = EXCLUDED.price_currency, " +
                    "stock_quantity = EXCLUDED.stock_quantity, " +
                    "status = EXCLUDED.status";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, product.getProductId());
            stmt.setString(2, product.getName());
            stmt.setString(3, product.getDescription());
            stmt.setBigDecimal(4, product.getPrice().getAmount());
            stmt.setString(5, product.getPrice().getCurrency());
            stmt.setInt(6, product.getStockQuantity());
            stmt.setString(7, product.getStatus().name());
            
            stmt.executeUpdate();
            return product;
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to save product", e);
        }
    }
    
    @Override
    public Optional<Product> findById(String productId) {
        String sql = "SELECT product_id, name, description, price_amount, " +
                    "price_currency, stock_quantity, status " +
                    "FROM products WHERE product_id = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, productId);
            
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapToProduct(rs));
                }
                return Optional.empty();
            }
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to find product", e);
        }
    }
    
    @Override
    public boolean existsById(String productId) {
        String sql = "SELECT COUNT(*) FROM products WHERE product_id = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            stmt.setString(1, productId);
            
            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return rs.getInt(1) > 0;
                }
                return false;
            }
            
        } catch (SQLException e) {
            throw new RepositoryException("Failed to check product existence", e);
        }
    }
    
    // Map database row to domain entity
    private Product mapToProduct(ResultSet rs) throws SQLException {
        Money price = new Money(
            rs.getBigDecimal("price_amount"),
            rs.getString("price_currency")
        );
        
        return new Product(
            rs.getString("product_id"),
            rs.getString("name"),
            rs.getString("description"),
            price,
            rs.getInt("stock_quantity")
        );
    }
}

The infrastructure layer contains all the database-specific code. It implements the repository port interface defined by the domain layer, translating between domain entities and database representations. Notice how the domain layer remains completely ignorant of SQL, JDBC, and PostgreSQL. All these concerns are isolated in the infrastructure layer.

Finally, the presentation layer handles HTTP requests and responses, translating between the external API and the application layer.

// PRESENTATION LAYER
// Contains REST API controllers that handle HTTP requests
// Depends on application layer for business operations
// Translates between HTTP and application layer concepts

/**
 * REST controller for product catalog operations.
 * 
 * This controller handles HTTP requests, validates input, invokes
 * application services, and formats responses. It translates between
 * HTTP concepts (requests, responses, status codes) and application
 * layer concepts (use cases, results).
 */
@RestController
@RequestMapping("/api/products")
public class ProductController {
    
    private final ProductCatalogService catalogService;
    
    public ProductController(ProductCatalogService catalogService) {
        this.catalogService = catalogService;
    }
    
    /**
     * Creates a new product.
     * 
     * This endpoint accepts a JSON request body, validates it, invokes
     * the application service, and returns an appropriate HTTP response.
     * 
     * @param request the product creation request
     * @return HTTP response with created product or error message
     */
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid CreateProductRequest request) {
        
        // Invoke application service
        ProductCreationResult result = catalogService.createProduct(request);
        
        if (result.isSuccess()) {
            // Return 201 Created with the created product
            ProductResponse response = ProductResponse.from(result.getProduct());
            return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(response);
        } else {
            // Return 400 Bad Request with error message
            ErrorResponse error = new ErrorResponse(result.getErrorMessage());
            return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(error);
        }
    }
    
    /**
     * Updates the price of a product.
     * 
     * @param productId the ID of the product to update
     * @param request the price update request
     * @return HTTP response indicating success or failure
     */
    @PutMapping("/{productId}/price")
    public ResponseEntity<Void> updatePrice(
            @PathVariable String productId,
            @RequestBody @Valid UpdatePriceRequest request) {
        
        Money newPrice = new Money(request.getAmount(), request.getCurrency());
        PriceUpdateResult result = catalogService.updatePrice(productId, newPrice);
        
        if (result.isSuccess()) {
            return ResponseEntity.ok().build();
        } else {
            return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .build();
        }
    }
}

The presentation layer handles all HTTP-specific concerns. It validates requests, invokes application services, and formats responses with appropriate status codes. It translates between JSON representations and domain objects. Notice how thin this layer is, with all business logic delegated to the application and domain layers.

This layered architecture provides clear separation of concerns. The domain layer contains pure business logic with no infrastructure dependencies. The application layer orchestrates use cases. The infrastructure layer provides concrete implementations of technical concerns. The presentation layer handles HTTP communication. Each layer has a well-defined responsibility and depends only on layers below it through abstractions.

CONCLUSION AND BEST PRACTICES

Prompting an LLM to generate architecturally excellent code requires careful attention to detail and explicit communication of requirements. We must specify not just what the code should do, but how it should be structured, what patterns it should use, what quality attributes it should prioritize, and how it should be documented.

The key to success lies in providing comprehensive context. Rather than asking for generic solutions, we should describe our specific situation, constraints, and goals. We should name the patterns we want to use and explain how they should be applied. We should specify quality attributes and their relative priorities. We should request documentation that explains not just what the code does, but why it does it.

Throughout this tutorial, we have explored techniques for embedding architectural requirements into prompts. We have seen how to request specific patterns like hexagonal architecture, strategy, observer, decorator, and repository. We have examined how different quality attributes influence architectural decisions. We have explored layered architecture and how to maintain clear separation of concerns. We have emphasized the importance of comprehensive documentation at multiple levels.

The examples throughout this tutorial demonstrate that well-crafted prompts can guide LLMs to generate code that exhibits sound architectural principles, applies appropriate patterns, meets quality attribute requirements, and provides a stable foundation for implementation. However, generating good code is only the first step. We must review the generated code carefully, verify that it meets our requirements, and refine our prompts based on what we learn.

Effective prompt engineering for architecture is an iterative process. We start with an initial prompt, examine the generated code, identify gaps or issues, and refine our prompt to address them. Over time, we develop a library of prompt patterns that consistently produce high-quality results for our specific context.

Remember that an LLM is a tool to augment human expertise, not replace it. The architectural decisions, pattern selections, and quality attribute priorities must come from human understanding of the problem domain and business requirements. The LLM helps us implement these decisions consistently and comprehensively, but it cannot make the fundamental architectural choices for us.

By mastering the art of architectural prompting, we can leverage LLMs to generate code that not only works but embodies the architectural principles and patterns that make software maintainable, testable, and adaptable to changing requirements. This represents a powerful combination of human architectural vision and machine implementation capability.

No comments: