The Aristotelian Approach to Writing Good Programs: Logic as the Foundation of Code

Most programming advice focuses on syntax, frameworks, or design patterns. But what if the secret to writing good programs isn't found in the latest JavaScript framework or the hottest architectural trend? What if it's found in 2,400-year-old principles of logic?
After years of debugging other people's code—and my own disasters—I've discovered that the most robust programs follow principles that Aristotle codified millennia ago. Not because ancient Greeks were particularly good at JavaScript or Python (they weren't), but because logical thinking transcends any particular technology.
The approach I'm about to share isn't just philosophical hand-waving. It's a practical methodology that has consistently produced more reliable, maintainable, and debuggable code than any framework-specific best practice I've encountered.
The Three Laws of Programming Logic
Before we dive into implementation, we need to establish the logical foundation. Aristotle's laws of thought map surprisingly well to the core problems we face in software development:
1. The Law of Identity: Name Things What They Actually Are
Aristotle: "A is A" — everything is identical to itself.
In Programming: A variable should be exactly what its name claims it to be, nothing more, nothing less.
This sounds obvious until you encounter code like this:
// What is userData actually?
const userData = await fetchUser(id);
// Is it user data? Or is it an HTTP response? Or an error object?
// The name lies about the identity.
// Better:
const userApiResponse = await fetchUser(id);
const userData = userApiResponse.data;
const userErrors = userApiResponse.errors;
The Law of Identity in programming means that when I see a variable named userEmail
, it should contain exactly that—a user's email address. Not sometimes an email, sometimes null, sometimes an error object, and sometimes a Boolean flag indicating whether the email was validated.
Practical Application:
- Name variables for what they contain, not what they might contain
- Use specific types rather than generic ones (
EmailAddress
notstring
) - If a function is named
calculateTotalPrice
, it should calculate and return a total price, not also update the UI and log to analytics
2. The Law of Non-Contradiction: A Variable Is Always What It's Supposed to Be
Aristotle: "Nothing can be both A and not-A at the same time."
In Programming: A variable cannot simultaneously be valid data and invalid data. It cannot be both a User object and an error condition.
This is where most bugs are born. We write code assuming a variable contains valid data, but somewhere along the execution path, it became something else entirely.
// Violates non-contradiction
function calculateTotal(price) {
// price could be a number, string, null, undefined, or object
return price * 1.08; // Will produce NaN, unexpected coercion, or crash
}
// Follows non-contradiction
function calculateTotal(price: number) {
if (typeof price !== 'number' || price < 0) {
throw new Error('Price must be a positive number');
}
// Now price is guaranteed to be exactly what we expect
return price * 1.08;
}
Practical Application:
- Use type systems that enforce these guarantees
- Validate inputs at boundaries, then trust them within the boundary
- Handle error cases explicitly, don't let them contaminate valid data flows
3. The Law of Sufficient Reason: You Can't Be the Only One Who Understands the Logic
Aristotle: "Everything that exists has a sufficient reason for its existence that can be discovered and understood"
In Programming: Your logic should be provably correct to any reasonably intelligent developer, not just to you at 2 AM after six cups of coffee.
// Insufficient proof (only you understand this)
const result = data.filter(x => x.type === 'active')
.map(x => ({ ...x, score: x.points * 2.5 + (x.bonus || 0) }))
.filter(x => x.score > threshold)
.sort((a, b) => b.score - a.score)[0];
// Sufficient proof (logic is demonstrable)
const activeItems = data.filter(item => item.type === 'active');
const scoredItems = activeItems.map(item => calculateItemScore(item));
const qualifyingItems = scoredItems.filter(item => item.score > threshold);
const topItem = findHighestScoringItem(qualifyingItems);
Practical Application:
- Break complex operations into named, single-purpose functions
- Write code that reads like a proof of its own correctness
- Use meaningful variable names that explain the transformation at each step
The Guiding Principle: Do Not Try Something That Will Fail
This principle sits at the heart of defensive programming, but taken to its logical conclusion: if you can predict that an operation might fail, validate the preconditions before attempting it.
The exception is network requests and other external system interactions, where failure is inherent to the medium and must be handled as a normal case.
// Trying something that might fail
function divide(a, b) {
return a / b; // Will return Infinity or NaN for invalid inputs
}
// Not trying something that will fail
function divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Division requires numeric inputs');
}
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
This principle transforms error handling from reactive debugging to proactive validation. You're not fixing problems after they occur; you're preventing them from occurring in the first place.
The Four-Block Structure
Now let's put these principles into practice with a structured approach to writing functions and classes. Every substantial piece of logic should follow this four-block pattern:
Block 1: Declaration (Identity and Purpose)
Purpose: Establish what this function is and what it operates on.
In this block, you:
- Gather all the data you'll need for the operation
- Declare your variables with meaningful names and types
- Make the function's contract explicit
function processPaymentTransaction(
customerAccount: CustomerAccount,
paymentMethod: PaymentMethod,
amount: MonetaryAmount,
orderDetails: OrderDetails
): PaymentResult {
// Declare what we'll be working with
const transactionId = generateTransactionId();
const timestamp = getCurrentTimestamp();
const currency = amount.currency;
// Gather external dependencies we'll need
const paymentProcessor = getPaymentProcessor(paymentMethod.type);
const fraudDetectionService = getFraudDetectionService();
This block answers the question: "What is this function, and what does it need to do its job?"
Block 2: Validation (Do Not Try Something That Will Fail)
Purpose: Ensure all preconditions are met before proceeding.
This is where you implement the "do not try something that will fail" principle:
// Validate customer exists and is in good standing
if (!customerAccount) {
throw new CustomerAccountNotFoundError();
}
if (customerAccount.status !== 'active') {
throw new CustomerAccountInactiveError(customerAccount.customerId);
}
// Validate payment method
if (!paymentMethod.isValid()) {
throw new InvalidPaymentMethodError(paymentMethod);
}
if (paymentMethod.isExpired()) {
throw new ExpiredPaymentMethodError(paymentMethod);
}
// Validate amount
if (amount.value <= 0) {
throw new InvalidAmountError('Amount must be positive');
}
if (amount.value > paymentMethod.creditLimit) {
throw new CreditLimitExceededError(amount, paymentMethod.creditLimit);
}
// Validate fraud detection
const fraudRisk = await fraudDetectionService.assessRisk(customerAccount, amount, paymentMethod);
if (fraudRisk.level === 'high') {
throw new FraudRiskTooHighError(fraudRisk);
}
This block answers the question: "Are we certain this operation can succeed?"
Block 3: Process and Transact (Proof)
Purpose: Execute the core logic with confidence that it will succeed.
Because you validated everything in Block 2, this block can focus purely on the business logic without defensive programming:
// Calculate final amount including fees
const processingFee = paymentProcessor.calculateFee(amount);
const totalAmount = amount.add(processingFee);
// Create the transaction record
const transaction = new PaymentTransaction({
id: transactionId,
customerId: customerAccount.customerId,
paymentMethod,
amount: totalAmount,
orderDetails,
timestamp,
status: 'processing'
});
// Process the payment
const paymentResult = await paymentProcessor.charge(
paymentMethod,
totalAmount,
transactionId
);
// Update transaction status
transaction.updateStatus(paymentResult.status);
transaction.setExternalTransactionId(paymentResult.externalId);
This block answers the question: "What exactly happens when all conditions are met?"
Block 4: Commit and Respond (Conscious Intention)
Purpose: Persist changes and provide meaningful responses.
This block handles the aftermath—committing state changes and providing useful feedback:
// Update customer account
await customerAccount.recordTransaction(transaction);
// Handle the result based on payment status
if (paymentResult.status === 'successful') {
await orderService.markAsPaid(orderDetails.id);
await notificationService.sendPaymentConfirmation(customerAccount, transaction);
return {
status: 'success',
transactionId,
amount: totalAmount,
confirmationCode: paymentResult.confirmationCode
};
}
await orderService.markAsPaymentFailed(orderDetails.id);
return {
status: 'failed',
transactionId,
reason: paymentResult.failureReason,
retryAllowed: paymentResult.retryable
};
}
This block answers the question: "What should the world look like after this operation, and what should the caller know about what happened?"
Error Handling Philosophy
Notice how error handling in this approach differs from typical exception-based programming:
Traditional Approach: Try something, catch exceptions if it fails
Aristotelian Approach: Validate preconditions, then execute with confidence
// Traditional: reactive error handling
async function transferMoney(fromAccount, toAccount, amount) {
try {
fromAccount.withdraw(amount);
toAccount.deposit(amount);
return { success: true };
} catch (error) {
// Now we have to figure out what went wrong and potentially rollback
return { success: false, error: error.message };
}
}
// Aristotelian: proactive validation
async function transferMoney(fromAccount, toAccount, amount) {
// Block 2: Validation
if (fromAccount.balance < amount) {
throw new InsufficientFundsError(fromAccount.balance, amount);
}
if (toAccount.status !== 'active') {
throw new AccountInactiveError(toAccount.id);
}
// Block 3: Process (we know this will succeed)
fromAccount.withdraw(amount);
toAccount.deposit(amount);
// Block 4: Commit
await accountRepository.saveAll([fromAccount, toAccount]);
return { success: true, newBalance: fromAccount.balance };
}
Runtime Validation: The Missing Piece
One of the most important insights from this approach is that types are not enough. TypeScript can't protect you from invalid data at runtime:
// Types don't prevent this from happening
const userInput: string = JSON.parse(request.body).email; // Could be anything
const email: EmailAddress = userInput; // TypeScript says this is fine
You need runtime validation that complements your type system:
function validateEmailAddress(input: unknown): EmailAddress {
if (typeof input !== 'string') {
throw new ValidationError('Email must be a string');
}
if (!input.includes('@') || !input.includes('.')) {
throw new ValidationError('Email must contain @ and . characters');
}
if (input.length > 254) {
throw new ValidationError('Email too long');
}
return input as EmailAddress;
}
// Now you can trust it
const email = validateEmailAddress(userInput);
When to Skip Validation
The only time you should skip validation is when language-level performance is paramount, and you're operating on data that has already been validated at the system boundary.
// Internal function operating on pre-validated data
function calculateVectorLength(x: number, y: number, z: number): number {
// No validation needed - this is called from validated contexts
return Math.sqrt(x * x + y * y + z * z);
}
// Public API function
function calculateDistance(point1: Point3D, point2: Point3D): number {
// Validation happens here
validatePoint3D(point1);
validatePoint3D(point2);
// Internal call doesn't need validation
return calculateVectorLength(
point2.x - point1.x,
point2.y - point1.y,
point2.z - point1.z
);
}
Practical Benefits
This approach might seem verbose compared to "move fast and break things" methodologies, but the benefits compound over time:
- Debugging becomes trivial because failures happen at validation boundaries, not deep in business logic
- Code review becomes easier because the structure makes intent explicit
- Testing becomes systematic because you can verify each block independently
- Maintenance becomes predictable because changes follow the same logical structure
Most importantly, you spend less time fixing bugs and more time building features because bugs become much rarer when you refuse to try operations that might fail.
Conclusion: Ancient Wisdom for Modern Problems
The Aristotelian approach to programming isn't about being academic or philosophical for its own sake. It's about recognizing that the fundamental challenges of programming—managing complexity, ensuring correctness, and communicating intent—are actually problems of logic that humans have been thinking about for millennia.
When you name things what they actually are, ensure variables are always what they claim to be, write code that proves its own correctness, and refuse to attempt operations that might fail, you're not just following ancient Greek philosophy. You're applying time-tested principles of clear thinking to the very modern problem of making computers do useful work.
The four-block structure gives you a practical framework for applying these principles consistently. And the result is code that doesn't just work—it works reliably, fails gracefully, and communicates its intent clearly to anyone who reads it.
In a world obsessed with the latest frameworks and architectural patterns, perhaps the most radical thing you can do is to write programs that follow 2,400-year-old principles of logic. Your future self—and anyone who has to maintain your code—will thank you for it.
JavaScript frameworks come and go, but logic is eternal.