Loading learning content...
Understanding the theory of object-oriented programming is one thing; successfully making the mental transition is another. Many developers learn OO syntax, use classes, and still write fundamentally procedural code. This page exposes the common traps that catch developers in transition and shows how to recognize and escape them.
These aren't obscure edge cases—they're patterns that experienced instructors see repeatedly. Learning to recognize them will accelerate your transition to genuine object-oriented thinking.
By the end of this page, you will recognize the most common anti-patterns in transitional code: anemic domain models, god classes, feature envy, procedural code in OO disguise, and premature abstraction. For each, you'll understand why it happens, how to spot it, and how to refactor toward better design.
The Anti-Pattern
An anemic domain model is perhaps the most common trap for developers transitioning from procedural to OO. It looks object-oriented—there are classes with attributes—but the classes have no behavior. All the logic lives in separate "service" classes that manipulate the domain objects.
Martin Fowler, who coined the term, describes it as "the fundamental horror of this anti-pattern... putting behavior in the domain objects."
What It Looks Like:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# ANEMIC: The Order class is just a data holderclass Order: def __init__(self): self.id = None self.customer_id = None self.items = [] self.status = 'PENDING' self.total = 0.0 self.shipping_address = None self.created_at = None # All behavior lives in a separate serviceclass OrderService: def __init__(self, inventory_repo, payment_gateway, email_service): self.inventory = inventory_repo self.payment = payment_gateway self.email = email_service def add_item_to_order(self, order, product, quantity): # External function manipulating Order data if self.inventory.check_availability(product.id, quantity): order.items.append({'product': product, 'quantity': quantity}) order.total += product.price * quantity def submit_order(self, order): # External function with all the logic if not order.items: raise ValueError("Order must have items") if not self.validate_shipping_address(order.shipping_address): raise ValueError("Invalid shipping address") if not self.payment.charge(order.customer_id, order.total): raise ValueError("Payment failed") order.status = 'CONFIRMED' self.email.send_confirmation(order) return order def cancel_order(self, order): if order.status != 'CONFIRMED': raise ValueError("Can only cancel confirmed orders") self.payment.refund(order.customer_id, order.total) order.status = 'CANCELLED' self.email.send_cancellation(order)Why This Happens
Developers fall into this pattern because:
What's Wrong With It
order.status = 'SHIPPED' without the proper transitions123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# RICH DOMAIN MODEL: Order contains its own behavior class Order: def __init__(self, customer: Customer): self._customer = customer self._items = [] self._status = OrderStatus.PENDING self._created_at = datetime.now() def add_item(self, product: Product, quantity: int) -> bool: """Order knows how to add items and validate quantity.""" if quantity <= 0: return False if not product.has_inventory(quantity): return False product.reserve(quantity) self._items.append(OrderItem(product, quantity)) return True @property def total(self) -> Money: """Order knows how to calculate its total.""" return sum((item.subtotal for item in self._items), Money.zero()) def submit(self, payment_processor: PaymentProcessor) -> bool: """Order knows its submission rules and orchestrates the process.""" if self._status != OrderStatus.PENDING: raise InvalidOrderStateError("Can only submit pending orders") if not self._items: raise InvalidOrderError("Order must have items") if not self._customer.shipping_address.is_valid(): raise InvalidAddressError("Invalid shipping address") if not payment_processor.charge(self._customer, self.total): self._release_reservations() return False self._status = OrderStatus.CONFIRMED self._customer.notify_order_confirmed(self) return True def cancel(self, payment_processor: PaymentProcessor): """Order knows its cancellation rules.""" if not self._status.allows_cancellation(): raise InvalidOrderStateError(f"Cannot cancel {self._status} order") payment_processor.refund(self._customer, self.total) self._release_reservations() self._status = OrderStatus.CANCELLED self._customer.notify_order_cancelled(self) def _release_reservations(self): for item in self._items: item.product.release(item.quantity)Ask yourself: 'What does this object KNOW how to DO?' If your answer is 'nothing—it just holds data,' you have an anemic model. Rich domain objects encapsulate both state AND the rules that govern that state. The Order should know what it means to be submitted, cancelled, or modified.
The Anti-Pattern
A god class is a class that knows too much or does too much. It becomes the gravitational center of the application, accumulating methods, dependencies, and responsibilities until it's a miniature procedural program hiding inside a class.
Warning Signs:
UserManager, SystemController, ApplicationService123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
# GOD CLASS: UserManager does everything related to users... and moreclass UserManager: def __init__( self, database, email_service, sms_service, payment_processor, analytics, cache, file_storage, notification_hub, audit_logger, feature_flags, # ... 10 more dependencies ): self.db = database self.email = email_service # ... store all dependencies # Authentication def login(self, username, password): ... def logout(self, user_id): ... def reset_password(self, email): ... def verify_email(self, token): ... def enable_2fa(self, user_id): ... def verify_2fa(self, user_id, code): ... # Profile management def update_profile(self, user_id, data): ... def upload_avatar(self, user_id, image): ... def change_email(self, user_id, new_email): ... def change_phone(self, user_id, new_phone): ... # Subscription management def subscribe(self, user_id, plan): ... def cancel_subscription(self, user_id): ... def upgrade_plan(self, user_id, new_plan): ... def process_payment(self, user_id, amount): ... # Notifications def send_notification(self, user_id, message): ... def update_preferences(self, user_id, prefs): ... def send_bulk_notification(self, user_ids, message): ... # Analytics def track_event(self, user_id, event): ... def generate_report(self, user_id): ... def export_data(self, user_id): ... # And 50 more methods...Why This Happens
Why It's Harmful
Break the god class into focused classes, each with a single responsibility. UserManager becomes AuthenticationService, ProfileService, SubscriptionService, NotificationService, etc. Each class is testable, understandable, and changeable independently. Apply the Single Responsibility Principle: A class should have one reason to change.
The Anti-Pattern
Feature envy occurs when a method in one class is more interested in the data of another class than its own. The method repeatedly accesses another object's data to perform calculations or decisions that should belong to that other object.
12345678910111213141516171819202122232425262728293031323334353637383940
# FEATURE ENVY: ReportGenerator is obsessed with Customer dataclass ReportGenerator: def generate_customer_report(self, customer): # This method knows too much about Customer's internals total_orders = len(customer.orders) total_spent = sum(o.total for o in customer.orders) avg_order = total_spent / total_orders if total_orders else 0 # Calculating recency using customer's data last_order = max(customer.orders, key=lambda o: o.date) if customer.orders else None days_since_last = (datetime.now() - last_order.date).days if last_order else None # Determining customer tier based on customer's data if total_spent > 10000: tier = "PLATINUM" elif total_spent > 5000: tier = "GOLD" elif total_spent > 1000: tier = "SILVER" else: tier = "BRONZE" # Building discount based on customer's data discount = 0 if customer.is_subscribed: discount += 5 if customer.years_as_customer > 2: discount += 3 if customer.referred_others: discount += 2 return CustomerReport( customer_id=customer.id, total_orders=total_orders, total_spent=total_spent, avg_order=avg_order, tier=tier, discount=discount, days_since_last=days_since_last )The Problem
Notice how generate_customer_report is deeply intimate with Customer's data: orders, subscription status, referral history, years as customer. This method belongs in the Customer class—it's doing Customer's job!
Symptoms:
customer.orders[0].items[0].product.category.nameRefactored: Feature Envy Resolved:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
# FIXED: Customer knows its own metricsclass Customer: def __init__(self, ...): self._orders = [] self._is_subscribed = False self._joined_date = None self._referred_count = 0 @property def total_orders(self) -> int: return len(self._orders) @property def total_spent(self) -> Money: return sum((o.total for o in self._orders), Money.zero()) @property def average_order_value(self) -> Money: if not self._orders: return Money.zero() return self.total_spent / self.total_orders @property def days_since_last_order(self) -> Optional[int]: if not self._orders: return None last = max(self._orders, key=lambda o: o.date) return (datetime.now() - last.date).days @property def tier(self) -> CustomerTier: """Customer determines its own tier.""" spent = self.total_spent.amount if spent > 10000: return CustomerTier.PLATINUM elif spent > 5000: return CustomerTier.GOLD elif spent > 1000: return CustomerTier.SILVER return CustomerTier.BRONZE @property def discount_percentage(self) -> int: """Customer calculates its own discount.""" discount = 0 if self._is_subscribed: discount += 5 if self.years_as_customer > 2: discount += 3 if self._referred_count > 0: discount += 2 return discount @property def years_as_customer(self) -> int: if not self._joined_date: return 0 return (datetime.now() - self._joined_date).days // 365 class ReportGenerator: def generate_customer_report(self, customer: Customer) -> CustomerReport: # Now just asks customer for its own metrics return CustomerReport( customer_id=customer.id, total_orders=customer.total_orders, total_spent=customer.total_spent, avg_order=customer.average_order_value, tier=customer.tier, discount=customer.discount_percentage, days_since_last=customer.days_since_last_order )When you find yourself repeatedly accessing another object's data to compute something, that computation probably belongs in the other object. Ask: 'Who owns this data? They should also own the behavior.' This is the 'Tell, Don't Ask' principle in action.
The Anti-Pattern
This is when developers use OO syntax—classes, methods, objects—but the underlying thinking remains procedural. The code has classes, but they're organized by procedure, not by domain concept. The code has objects, but they're passed through processing pipelines rather than collaborating as autonomous agents.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
# PROCEDURAL IN DISGUISE: Classes organized by action, not concept class OrderValidator: def validate(self, order_data: dict) -> dict: errors = [] if not order_data.get('items'): errors.append('No items') if not order_data.get('customer_id'): errors.append('No customer') return {'valid': len(errors) == 0, 'errors': errors} class OrderCalculator: def calculate_total(self, order_data: dict) -> float: return sum(item['price'] * item['qty'] for item in order_data['items']) class OrderPersister: def save(self, order_data: dict) -> dict: order_data['id'] = generate_id() self.db.insert('orders', order_data) return order_data class OrderNotifier: def notify(self, order_data: dict): self.email.send(order_data['customer_email'], 'Order confirmed!') # The "orchestrator" - a procedural main function in disguiseclass OrderProcessor: def __init__(self, validator, calculator, persister, notifier): self.validator = validator self.calculator = calculator self.persister = persister self.notifier = notifier def process(self, order_data: dict): # This is procedural step-by-step processing result = self.validator.validate(order_data) if not result['valid']: return result order_data['total'] = self.calculator.calculate_total(order_data) order_data = self.persister.save(order_data) self.notifier.notify(order_data) return {'success': True, 'order': order_data}What's Wrong
This code has classes, but:
Order object—just order_data dicts being passed aroundThe Telltale Signs:
*Validator, *Calculator, *Processor, *Handlerexecute(), run(), process(), handle()Start with domain concepts—the nouns. Order, Customer, Product, Payment. Give them rich behavior. Let them collaborate by sending messages. Validation, calculation, and notification become behaviors OF the Order object, not external procedures performed ON order data.
The Anti-Pattern
Developers new to OO often over-apply inheritance, creating deep class hierarchies for problems that don't warrant them or using inheritance for code reuse when composition would be better.
123456789101112131415161718192021222324252627282930313233
# INHERITANCE GONE WRONGclass Animal: def eat(self): ... def sleep(self): ... class Bird(Animal): def fly(self): ... # Seems reasonable class Duck(Bird): def quack(self): ... def swim(self): ... class RubberDuck(Duck): # Uh oh... def fly(self): raise Exception("Rubber ducks can't fly!") def eat(self): raise Exception("Rubber ducks don't eat!") def sleep(self): raise Exception("Rubber ducks don't sleep!") def swim(self): # At least this works return "floating" def squeak(self): # Different from quack! return "squeak!" # Now every method that takes a Duck and calls fly() breaks with RubberDuckdef migrate(ducks: List[Duck]): for duck in ducks: duck.fly() # RubberDuck throws exception!The Problems
When Does This Happen?
| Use Inheritance When | Use Composition When |
|---|---|
| True IS-A relationship | Object HAS-A or USES-A relationship |
| Subtype can substitute for parent | Different behaviors at different times |
| Shared behavior across entire hierarchy | Sharing behavior across unrelated types |
| Stable hierarchy unlikely to change | Flexible combinations of capabilities |
Instead of RubberDuck extending Duck, create separate interfaces: Swimmable, Quackable, Flyable. Real ducks implement all three; rubber ducks implement Swimmable and add their own Squeakable. Compose behaviors rather than inheriting entire hierarchies.
The Anti-Pattern
Developers sometimes introduce abstractions (interfaces, base classes, design patterns) before they're needed, anticipating variations that may never come. This creates complexity without benefit.
1234567891011121314151617181920212223242526272829303132333435363738394041
# OVER-ENGINEERED: Only ever used with one implementation from abc import ABC, abstractmethod class IUserRepository(ABC): @abstractmethod def find_by_id(self, id): ... @abstractmethod def find_by_email(self, email): ... @abstractmethod def save(self, user): ... class IUserRepositoryFactory(ABC): @abstractmethod def create(self) -> IUserRepository: ... class PostgresUserRepository(IUserRepository): # The only actual implementation def find_by_id(self, id): ... def find_by_email(self, email): ... def save(self, user): ... class PostgresUserRepositoryFactory(IUserRepositoryFactory): def create(self) -> IUserRepository: return PostgresUserRepository() # Usageclass UserService: def __init__(self, repo_factory: IUserRepositoryFactory): self.repo = repo_factory.create() def get_user(self, id): return self.repo.find_by_id(id) # Throughout the codebase:factory = PostgresUserRepositoryFactory()service = UserService(factory) # The interface and factory add NO value - there's only one implementation!The Problem
All this abstraction for ONE implementation! If there's only PostgresUserRepository and no plans for others, the IUserRepository interface is just noise:
The Rule of Three
A useful heuristic: Don't abstract until you have three cases. One implementation is concrete. Two implementations might be coincidence. Three implementations reveal the real pattern worth abstracting.
Start concrete. Write PostgresUserRepository directly. When (if!) you need MongoUserRepository, THEN extract the interface. Refactoring from concrete to abstract is straightforward; refactoring from over-abstract to simple is painful. Let the code tell you when it needs abstraction.
The Anti-Pattern
A subtle but pervasive problem: creating getters and setters for every field, effectively making all data public while claiming to have "encapsulation."
1234567891011121314151617181920
# ILLUSION OF ENCAPSULATIONclass BankAccount: def __init__(self, balance: float): self._balance = balance def get_balance(self) -> float: return self._balance def set_balance(self, balance: float): self._balance = balance # No validation! # Client code can do anything:account = BankAccount(1000)account.set_balance(-500) # Invalid state - no protection!account.set_balance(account.get_balance() - 100) # Manual withdrawal # This is functionally identical to public fields:class BankAccount: def __init__(self, balance: float): self.balance = balance # Just make it public then!Why This Happens
True Encapsulation
Encapsulation isn't about getter/setter syntax—it's about behavioral interfaces. Instead of exposing balance for external manipulation, expose behaviors:
1234567891011121314151617181920212223242526272829303132333435
# TRUE ENCAPSULATION: Behavior, not data accessclass BankAccount: def __init__(self, initial_balance: float): if initial_balance < 0: raise ValueError("Initial balance cannot be negative") self._balance = initial_balance def deposit(self, amount: float) -> bool: """Add funds to account. Returns True if successful.""" if amount <= 0: return False self._balance += amount return True def withdraw(self, amount: float) -> bool: """Remove funds from account. Returns True if successful.""" if amount <= 0: return False if amount > self._balance: return False self._balance -= amount return True def get_balance(self) -> float: """Read-only access to balance.""" return self._balance # NO set_balance method! # External code cannot arbitrarily change the balance # It must use deposit() or withdraw() which enforce rules # Now client code must respect the account's behavioraccount = BankAccount(1000)account.withdraw(100) # Proper withdrawal# account.set_balance(-500) # NOT POSSIBLEFor each field, ask: 'Should external code be able to see this? Modify it? Under what conditions?' Often the answer is 'modify through controlled operations only.' Getters for read access are often fine; setters should be rare and thoughtful. Prefer methods that represent domain operations.
Transitioning to object-oriented thinking is a journey with predictable pitfalls. By learning to recognize these patterns, you can catch yourself falling into them and course-correct early.
| Pitfall | Warning Sign | Fix |
|---|---|---|
| Anemic Domain Model | Classes with only getters/setters; all logic in services | Push behavior into domain objects |
| God Class | Thousands of lines; dozens of methods; many dependencies | Extract cohesive classes by responsibility |
| Feature Envy | Methods obsessed with another object's data | Move behavior to the object that owns the data |
| Procedural Disguise | Classes named by verbs; data passed as dicts | Model domain nouns as rich objects |
| Inheritance Obsession | Deep hierarchies; broken substitution | Prefer composition; use interfaces |
| Premature Abstraction | Interfaces with one implementation | Start concrete; abstract when needed |
| Getter/Setter Explosion | Every field has accessors; no behavioral methods | Expose behavior, not data |
Module Complete:
You've now completed the foundational module on the paradigm shift from procedural to object-oriented thinking. You understand:
With this foundation, you're prepared to dive deeper into the specific principles and techniques of object-oriented design. The next modules will explore how to identify objects in problem statements, model entities with appropriate behaviors, and think systematically in components.
Congratulations! You've completed Module 1: From Procedural to Object-Oriented Thinking. You now have the conceptual foundation to understand why OO design works, what traps to avoid, and how to shift your mental model. Apply these insights as you continue learning—and revisit these pitfalls periodically; even experienced developers fall into them occasionally.