Lesson 1 - Core Principles of Software Engineering
On this page
- Welcome to Core Principles of Software Engineering
- Modularity: Breaking Work Into Focused Pieces
- Abstraction: Hiding How, Exposing What
- Encapsulation: Protecting Internal State
- Reusability and DRY: Don’t Repeat Yourself
- KISS: Keep It Simple
- YAGNI: You Aren’t Gonna Need It
- Practice Exercises
- Summary
- Next Steps
- Continue Building Your Skills
Welcome to Core Principles of Software Engineering
Imagine three developers building Ledgerly, a small invoicing and subscription-billing app for freelancers and small businesses. In the first month, the code works. By the third month, one small change to how discounts apply breaks the email receipts, and nobody can say why. This is not bad luck. It is what happens when code grows without a few basic principles guiding it. This lesson introduces those principles, and you will use Ledgerly’s codebase — its Invoice, Customer, Subscription, and PaymentGateway classes — to see each one in practice.
These principles are not abstract theory. They are the daily decisions a working engineer makes: where to draw a boundary between two pieces of code, what to hide from the rest of the system, and when to stop adding features nobody asked for. Get them right, and Ledgerly stays easy to change for years. Get them wrong, and every new feature gets slower and riskier to add.
By the end of this lesson, you will be able to:
- Explain modularity and split a tangled function into focused, single-purpose components
- Use abstraction to hide implementation details behind a stable interface
- Apply encapsulation to protect an object’s internal state from invalid changes
- Recognize DRY violations and extract repeated logic into one reusable place
- Apply KISS and YAGNI to avoid building complexity the project does not need yet
Modularity: Breaking Work Into Focused Pieces
Modularity means splitting a system into separate components, where each component has one clear job and depends on as little else as possible. A well-designed module can be read, tested, and changed on its own, without you having to hold the entire system in your head at once.
Here is how not to build Ledgerly’s invoice processing. This single function validates the customer, calculates the total, and applies a discount, all in one place.
def process_invoice(customer_id, line_items, tier):
# Validate
if not customer_id or customer_id <= 0:
return None
# Calculate total
subtotal = sum(item["price"] * item["quantity"] for item in line_items)
# Apply tier discount
discount_rates = {"gold": 0.15, "silver": 0.10, "bronze": 0.0}
total = subtotal * (1 - discount_rates.get(tier, 0.0))
# Pretend to save the invoice and email the customer here too
return round(total, 2)
result = process_invoice(101, [{"price": 40, "quantity": 3}], "gold")
print(result)102.0The function works, but it mixes three unrelated concerns: checking input, doing math, and (in a real version) saving data and sending email. Change the discount rule, and you risk breaking validation by accident, because everything lives in the same block of code.
The modular version separates each concern into its own class, so each one has a single reason to change.
class InvoiceValidator:
"""Checks that invoice inputs are valid before any money changes hands."""
def validate_customer(self, customer_id):
return bool(customer_id) and customer_id > 0
def validate_line_items(self, line_items):
return all(
item.get("price", 0) > 0 and item.get("quantity", 0) > 0
for item in line_items
)
class PricingEngine:
"""Turns line items and a plan tier into a final invoice total."""
DISCOUNT_RATES = {"gold": 0.15, "silver": 0.10, "bronze": 0.0}
def calculate_subtotal(self, line_items):
return sum(item["price"] * item["quantity"] for item in line_items)
def apply_tier_discount(self, subtotal, tier):
rate = self.DISCOUNT_RATES.get(tier, 0.0)
return subtotal * (1 - rate)
class InvoiceProcessor:
"""Coordinates validation and pricing to produce one invoice total."""
def __init__(self):
self.validator = InvoiceValidator()
self.pricing = PricingEngine()
def process(self, customer_id, line_items, tier):
if not self.validator.validate_customer(customer_id):
raise ValueError("Invalid customer")
if not self.validator.validate_line_items(line_items):
raise ValueError("Invalid line items")
subtotal = self.pricing.calculate_subtotal(line_items)
total = self.pricing.apply_tier_discount(subtotal, tier)
return round(total, 2)
processor = InvoiceProcessor()
total = processor.process(101, [{"price": 40, "quantity": 3}], "gold")
print(total)102.0Both versions produce the same number, but the second one is easier to change safely. If Ledgerly adds a platinum tier next month, only PricingEngine needs to change. If a new fraud check is needed, only InvoiceValidator grows. Two developers can even work on these two classes at the same time without stepping on each other.
Abstraction: Hiding How, Exposing What
Abstraction means defining what a piece of code does, without exposing how it does it. The rest of the system talks to a simple, stable interface, while the messy implementation details stay hidden behind it. This lets you change or replace the implementation later without breaking anything that depends on it.
Ledgerly needs to charge customers through a payment provider. Instead of writing Stripe-specific code directly into the billing logic, define an abstract PaymentGateway that any provider can implement.
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
"""Defines what a payment gateway must do, not how it does it."""
@abstractmethod
def charge(self, amount_cents, customer_token):
pass
@abstractmethod
def refund(self, charge_id, amount_cents):
pass
class StripeGateway(PaymentGateway):
"""Talks to Stripe's API. Only this class knows Stripe's details."""
def charge(self, amount_cents, customer_token):
return {"charge_id": f"stripe_ch_{customer_token}", "amount": amount_cents}
def refund(self, charge_id, amount_cents):
return {"refund_id": f"stripe_re_{charge_id}", "amount": amount_cents}
class TestGateway(PaymentGateway):
"""A fake gateway used in tests. No network calls, same interface."""
def __init__(self):
self.charges = []
def charge(self, amount_cents, customer_token):
charge_id = f"test_ch_{len(self.charges)}"
self.charges.append((charge_id, amount_cents))
return {"charge_id": charge_id, "amount": amount_cents}
def refund(self, charge_id, amount_cents):
return {"refund_id": f"test_re_{charge_id}", "amount": amount_cents}
class BillingService:
"""Charges customers for invoices without caring which gateway is used."""
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def collect_payment(self, invoice_total_cents, customer_token):
return self.gateway.charge(invoice_total_cents, customer_token)
live_billing = BillingService(StripeGateway())
test_billing = BillingService(TestGateway())
print(live_billing.collect_payment(4200, "cust_88"))
print(test_billing.collect_payment(4200, "cust_88")){'charge_id': 'stripe_ch_cust_88', 'amount': 4200}
{'charge_id': 'test_ch_0', 'amount': 4200}BillingService never mentions Stripe by name. It only calls charge() on whatever gateway it was given. This means Ledgerly’s three-person team can test billing logic with TestGateway and never touch a real payment network, and if they later add a second provider, BillingService does not change at all.
Encapsulation: Protecting Internal State
Encapsulation means bundling an object’s data with the methods that are allowed to change it, and blocking any other code from reaching in and changing that data directly. This keeps the object in a valid state no matter who is using it.
Ledgerly’s Invoice class tracks how much a customer has paid. If any code could freely set amount_paid, nothing would stop it from being set higher than the amount actually owed.
class Invoice:
"""Tracks how much a customer owes and enforces payment rules."""
def __init__(self, invoice_id, amount_due_cents):
self._invoice_id = invoice_id
self._amount_due_cents = amount_due_cents
self._amount_paid_cents = 0
self._status = "open"
@property
def status(self):
return self._status
@property
def balance_cents(self):
return self._amount_due_cents - self._amount_paid_cents
def record_payment(self, amount_cents):
if self._status == "paid":
raise ValueError("Invoice is already paid")
if amount_cents <= 0:
raise ValueError("Payment amount must be positive")
if amount_cents > self.balance_cents:
raise ValueError("Payment exceeds remaining balance")
self._amount_paid_cents += amount_cents
if self.balance_cents == 0:
self._status = "paid"
invoice = Invoice("INV-1001", 4200)
invoice.record_payment(2000)
print(invoice.status, invoice.balance_cents)
invoice.record_payment(2200)
print(invoice.status, invoice.balance_cents)
try:
invoice.record_payment(100)
except ValueError as error:
print(f"Blocked: {error}")open 2200
paid 0
Blocked: Invoice is already paidEvery change to the balance goes through record_payment(), so the invoice can never end up overpaid or paid twice. The underscore prefix on _amount_paid_cents and _status signals that outside code should not touch them directly. The only way in is through the methods Invoice provides, which enforce the rules every single time.
Why the underscore matters
Python does not physically prevent access to invoice._amount_paid_cents — the underscore is a convention, not a lock. Encapsulation works because the whole team agrees to respect it and always go through the public methods. That agreement is what keeps Invoice trustworthy as Ledgerly’s codebase grows past three developers.
Reusability and DRY: Don’t Repeat Yourself
DRY stands for “don’t repeat yourself.” It means every piece of logic should exist in exactly one place in your codebase. When the same rule is copied into two functions, fixing a bug means finding and updating both copies — and it is easy to miss one.
Ledgerly’s Customer records need the same validation whether a customer is being created or updated: a real email, and a name long enough to be useful. Writing that check twice creates two places that can drift apart.
class CustomerValidator:
"""One place that decides what counts as a valid customer record."""
@staticmethod
def validate_email(email):
if not email or "@" not in email:
raise ValueError("Invalid email")
return email
@staticmethod
def validate_name(name):
if not name or len(name) < 2:
raise ValueError("Name too short")
return name
class CustomerService:
"""Both create and update reuse the same validation rules."""
def create_customer(self, name, email):
name = CustomerValidator.validate_name(name)
email = CustomerValidator.validate_email(email)
return {"name": name, "email": email}
def update_customer(self, customer_id, name, email):
name = CustomerValidator.validate_name(name)
email = CustomerValidator.validate_email(email)
return {"id": customer_id, "name": name, "email": email}
service = CustomerService()
print(service.create_customer("Amara Okafor", "[email protected]"))
print(service.update_customer(7, "Amara O.", "[email protected]")){'name': 'Amara Okafor', 'email': '[email protected]'}
{'id': 7, 'name': 'Amara O.', 'email': '[email protected]'}If Ledgerly later needs to reject disposable email domains, that rule gets added in exactly one method, validate_email(), and both create_customer() and update_customer() benefit immediately. Reusable components like CustomerValidator also make testing cheaper, since you write the tests for the validation rule once and trust it everywhere it is reused.
Rule of three
You do not need to extract shared logic the first time you write it, or even the second. A common guideline is the rule of three: when you are about to write nearly identical code for the third time, that is your signal to stop and extract it into one reusable place.
KISS: Keep It Simple
KISS stands for “keep it simple.” Between two designs that solve the same problem, the simpler one is usually the better one, because it is easier to read, test, and safely change later.
Suppose someone on Ledgerly’s team builds an invoice number generator with configuration options for prefixes, padding, separators, and custom suffixes — none of which the product actually uses yet.
# Over-engineered: configurable formatting nobody asked for yet
class InvoiceNumberGenerator:
def __init__(self, prefix="INV", padding=6, separator="-", start=1,
allow_custom_suffix=False, suffix_rules=None):
self.prefix = prefix
self.padding = padding
self.separator = separator
self.counter = start
self.allow_custom_suffix = allow_custom_suffix
self.suffix_rules = suffix_rules or {}
def next_number(self, suffix_key=None):
number = str(self.counter).zfill(self.padding)
suffix = ""
if self.allow_custom_suffix and suffix_key in self.suffix_rules:
suffix = self.suffix_rules[suffix_key]
self.counter += 1
return f"{self.prefix}{self.separator}{number}{suffix}"
# Simple: does exactly what Ledgerly needs today
class SimpleInvoiceNumberGenerator:
def __init__(self, start=1):
self.counter = start
def next_number(self):
number = str(self.counter).zfill(6)
self.counter += 1
return f"INV-{number}"
generator = SimpleInvoiceNumberGenerator()
print(generator.next_number())
print(generator.next_number())INV-000001
INV-000002Both classes produce invoice numbers. The simple one is five lines shorter, has no unused parameters to understand, and cannot be misconfigured, because there is nothing to configure. Every option in the first version is a question a new developer has to answer before they can safely use it, even though Ledgerly has no current need for custom prefixes or suffixes.
YAGNI: You Aren’t Gonna Need It
YAGNI stands for “you aren’t gonna need it.” It warns against building features, flexibility, or infrastructure for requirements that do not exist yet, on the assumption that they might show up later. Building for a hypothetical future usually costs more than waiting and adding the real feature when it actually arrives.
Ledgerly currently sends one kind of notification: a payment reminder email. Building a full multi-channel plugin system now, before SMS or push notifications are on the roadmap, adds cost without adding value.
# Before: a multi-channel plugin system built before any channel but email exists
class NotificationDispatcher:
def __init__(self):
self.channels = {}
self.retry_policies = {}
self.delivery_log = []
def register_channel(self, name, sender, retry_policy=None):
self.channels[name] = sender
self.retry_policies[name] = retry_policy
def notify(self, channel, recipient, message):
sender = self.channels.get(channel)
if not sender:
raise ValueError(f"Unknown channel: {channel}")
result = sender(recipient, message)
self.delivery_log.append(result)
return result
# After: Ledgerly only sends payment-reminder emails right now.
# YAGNI: don't build a multi-channel plugin system before you need one.
class EmailNotificationService:
def send_payment_reminder(self, customer_email, invoice_id):
message = f"Reminder: invoice {invoice_id} is due soon"
print(f"Email to {customer_email}: {message}")
return {"channel": "email", "recipient": customer_email}
notifier = EmailNotificationService()
notifier.send_payment_reminder("[email protected]", "INV-000042")Email to [email protected]: Reminder: invoice INV-000042 is due soonEmailNotificationService does exactly what Ledgerly needs right now: send an email reminder. If Ledgerly’s customers later ask for SMS reminders, the team can design a real multi-channel system informed by an actual second channel, rather than guessing today what a hypothetical future channel might require.
Practice Exercises
Exercise 1: Split a tangled function
Ledgerly has a function generate_monthly_statement(customer_id, invoices) that in one block: checks the customer exists, sums up all invoice totals for the month, formats the sum as a currency string, and prints it. Describe how you would split this into at least two focused classes, and name each one’s single responsibility.
Hint
A reasonable split: a CustomerValidator (or reuse the existing one) to confirm the customer exists, a StatementCalculator that sums invoice totals for the month, and a StatementFormatter that turns the number into a display string. Each class has exactly one reason to change — validation rules, summing logic, or display formatting — and none of them need to know about the others’ internals.
Exercise 2: Add a second payment gateway
Using the PaymentGateway abstraction from this lesson, sketch a new PayPalGateway class that implements charge() and refund(). What does BillingService need to change to start using it instead of StripeGateway?
Hint
PayPalGateway subclasses PaymentGateway and implements both abstract methods with PayPal-specific logic. BillingService needs zero changes — you construct it with BillingService(PayPalGateway()) instead of BillingService(StripeGateway()). This is the whole point of depending on an abstraction: new implementations plug in without touching the code that uses them.
Exercise 3: Spot the YAGNI violation
A teammate proposes adding a discount_scheduling_engine to Ledgerly’s PricingEngine that can queue future discount changes to apply automatically at a specific date and time, even though no customer has asked for scheduled discounts yet. Is this a YAGNI violation? What would you suggest instead?
Hint
Yes — this builds speculative complexity for a requirement that does not exist yet. A better approach is to ship the current PricingEngine as-is, and only design scheduled discounts once a real customer or business need appears. At that point, the team can design around the actual requirement instead of a guess, usually resulting in a simpler and more accurate solution.
Summary
Six principles keep a codebase like Ledgerly’s maintainable as it grows. Modularity breaks the system into focused, independent pieces, like InvoiceValidator, PricingEngine, and InvoiceProcessor. Abstraction hides implementation details behind a stable interface, like PaymentGateway, so BillingService never depends on Stripe directly. Encapsulation protects an object’s internal state, like Invoice’s balance, so it can only change through methods that enforce the rules. DRY (don’t repeat yourself) puts each piece of logic, like customer validation, in exactly one reusable place. KISS keeps the design as simple as the problem allows, and YAGNI stops the team from building for requirements that do not exist yet, like a notification system with channels nobody uses.
Key Concepts
- Modularity — splitting a system into independent components, each with a single, well-defined responsibility.
- Abstraction — exposing what a component does through a stable interface, while hiding how it does it.
- Encapsulation — bundling data with the methods that are allowed to change it, and blocking direct access to internal state.
- DRY (Don’t Repeat Yourself) — every piece of logic exists in exactly one authoritative place.
- KISS (Keep It Simple) — prefer the simpler of two designs that solve the same problem.
- YAGNI (You Aren’t Gonna Need It) — do not build for requirements you do not have yet.
Why This Matters
These six principles are not academic rules — they are the difference between a codebase that gets easier to extend and one that gets harder with every feature. A team that applies them consistently can add a new payment provider, a new discount tier, or a new notification channel without rewriting what already works. Skip them, and even a small app like Ledgerly turns into a system where every change risks breaking something unrelated. The next lesson builds directly on this foundation with the SOLID principles, five more specific guidelines for structuring object-oriented code so that it stays open to new features without requiring you to rewrite what already works.
Next Steps
Lesson 2: The SOLID Principles
Learn the five SOLID principles for designing object-oriented systems that stay flexible as Ledgerly grows.
Back to Module Overview
Return to the Engineering Foundations module overview
Continue Building Your Skills
You now have the six core principles that shape every design decision the rest of this course builds on: modularity, abstraction, encapsulation, DRY, KISS, and YAGNI. In the next lesson, you will use these same Ledgerly components — Invoice, Customer, PaymentGateway, and more — to learn the SOLID principles, a more specific set of rules for structuring object-oriented code so it stays easy to extend.