Loading content...
You've identified a candidate for refactoring—an inheritance hierarchy that's causing pain, slowing down development, and accumulating defects. Now comes the critical question: How do you actually perform the transformation without breaking the system?
Refactoring from inheritance to composition is not a single operation but a carefully choreographed sequence of small, verifiable steps. Each step maintains the system in a working state, allowing you to pause, verify correctness, and even roll back if necessary. This incremental approach transforms what could be a high-risk "big bang" rewrite into a controlled evolution.
By the end of this page, you will understand the systematic step-by-step process for refactoring inheritance to composition. You'll learn specific transformation techniques, when to apply each, and how to maintain system integrity throughout the process.
Before diving into specific techniques, we need to establish the philosophical foundation that guides safe refactoring. These principles apply beyond inheritance-to-composition transformations to all significant code restructuring.
The Mikado Method, named after the game where you must remove sticks without disturbing others, teaches us that complex refactorings succeed through dependency-aware sequencing. The key insight: don't start by changing what seems most important. Instead, start by identifying all dependencies, then work backwards from the leaves of the dependency tree.
"Leave the code better than you found it" scales to refactoring projects. Each step should:
Resist the temptation to 'just rewrite it.' Big bang refactorings have a terrible track record. They take longer than estimated, introduce bugs, and often never complete. The system continues evolving while you rewrite, creating a moving target. Incremental refactoring works; rewrites rarely do.
Refactoring from inheritance to composition follows a predictable pattern. While specific details vary by context, the overall structure remains consistent. Here is the roadmap:
| Phase | Key Actions | Risk Level | Duration |
|---|---|---|---|
| Ensure test coverage, document current behavior, identify dependencies | Low | 1-2 days |
| Create interfaces representing the capabilities currently inherited | Low | 1 day |
| Implement capability interfaces as standalone component classes | Medium | 2-5 days |
| Add composed objects to subclasses, delegate to them | Medium | 2-3 days |
| Remove inheritance relationships, rely on composition | High | 1-2 days |
| Remove dead code, consolidate interfaces, optimize | Low | 1 day |
Each phase has specific goals, techniques, and verification criteria. Let's explore each in detail.
The preparation phase creates the safety net that makes confident refactoring possible. Skip this phase at your peril—most failed refactorings can trace their problems back to inadequate preparation.
Before modifying any code, ensure you have tests that capture the current behavior. These tests serve as your behavioral specification—if the tests pass after refactoring, you've preserved correctness.
Coverage targets:
If coverage is insufficient: Write characterization tests that capture current behavior, even if you're not sure that behavior is correct. The goal is documenting what IS, not what should be.
12345678910111213141516171819202122232425262728293031323334353637
// Characterization tests capture EXISTING behavior// Even if the behavior seems wrong, document it class NotificationBehaviorTests { @Test void emailNotification_withNullSubject_sendsWithEmptySubject() { // This might be a bug, but it's current behavior // Document it so refactoring doesn't accidentally "fix" it EmailNotification email = new EmailNotification( "user@example.com", null, // null subject "Body text" ); email.send(); // Verify the null becomes empty string (current behavior) verify(smtpClient).send(argThat(msg -> msg.getSubject().equals("") )); } @Test void smsNotification_oversizedContent_truncatesAt160Chars() { // Another behavior to preserve String longContent = "A".repeat(200); SmsNotification sms = new SmsNotification( "+1234567890", longContent ); String formatted = sms.formatContent(); assertEquals(160, formatted.length()); }}Create diagrams and documentation capturing:
This documentation helps you understand impact and serves as a reference while refactoring.
Map every place in the codebase that:
instanceof checks)This inventory guides the sequencing of changes. You must update dependents as you transform the hierarchy.
Use IDE features like 'Find All References' and 'Find Usages' to automatically discover dependencies. Static analysis tools can generate dependency graphs. Invest in understanding your IDE's refactoring support before starting.
Interface extraction creates the abstractions that composition will use. This phase identifies the capabilities embedded in the inheritance hierarchy and expresses them as explicit interfaces.
Analyze your base class and its subclasses. Ask: What distinct behaviors or capabilities exist? Look for:
Each distinct capability becomes a candidate interface.
12345678910111213141516171819202122232425262728293031323334
// BEFORE: Methods mixed in base class abstract class Notification { protected String recipient; protected String content; // Capability 1: Content formatting public abstract String formatContent(); // ← Formatting concern // Capability 2: Validation public abstract boolean validate(); // ← Validation concern // Capability 3: Delivery public abstract void send(); // ← Delivery concern // Capability 4: Retry logic (some subclasses override, some don't) public void sendWithRetry(int maxAttempts) { for (int i = 0; i < maxAttempts; i++) { try { send(); return; } catch (Exception e) { if (i == maxAttempts - 1) throw e; sleep(exponentialBackoff(i)); } } }} // IDENTIFIED CAPABILITIES:// 1. ContentFormatter - formatContent()// 2. Validator - validate() // 3. DeliveryChannel - send()// 4. RetryPolicy - sendWithRetry() logicFor each identified capability, create an interface. Initially, these interfaces may look like they're extracting a single method—that's fine. The granularity can be refined later.
123456789101112131415161718192021222324252627282930313233343536373839
// AFTER: Extracted capability interfaces /** * Responsible for transforming content into format appropriate * for the delivery channel. */interface ContentFormatter { String format(String rawContent, FormattingContext context);} /** * Validates that a notification is well-formed and ready to send. */interface NotificationValidator { ValidationResult validate(NotificationData data);} /** * Handles the actual delivery of a notification to a recipient. */interface DeliveryChannel { DeliveryResult deliver(FormattedNotification notification); boolean supportsRecipient(String recipient);} /** * Defines how failed deliveries should be retried. */interface RetryPolicy { boolean shouldRetry(int attemptNumber, Exception lastError); Duration getDelay(int attemptNumber);} // Supporting types for cleaner interfacesrecord FormattingContext(String recipientType, Map<String, String> metadata) {}record NotificationData(String recipient, String content, Map<String, Object> attributes) {}record FormattedNotification(String content, Map<String, String> headers) {}record ValidationResult(boolean valid, List<String> errors) {}record DeliveryResult(boolean success, String messageId, String error) {}Prefer multiple focused interfaces over one large interface. This follows the Interface Segregation Principle (ISP). It's easier to compose small interfaces than to implement large ones where you only need a subset of capabilities.
After extracting interfaces:
With interfaces defined, now implement them as standalone components. This is where the logic currently embedded in the inheritance hierarchy gets extracted into composable pieces.
For each interface, create concrete implementations that encapsulate the previously inherited behavior. Each implementation should be:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Extracted Component Implementations // ContentFormatter implementationsclass HtmlEmailFormatter implements ContentFormatter { private final TemplateEngine templateEngine; public HtmlEmailFormatter(TemplateEngine templateEngine) { this.templateEngine = templateEngine; } @Override public String format(String rawContent, FormattingContext context) { Map<String, Object> variables = Map.of( "content", rawContent, "recipientType", context.recipientType(), "metadata", context.metadata() ); return templateEngine.render("email-template.html", variables); }} class PlainTextFormatter implements ContentFormatter { private final int maxLength; public PlainTextFormatter(int maxLength) { this.maxLength = maxLength; } @Override public String format(String rawContent, FormattingContext context) { if (rawContent.length() <= maxLength) { return rawContent; } return rawContent.substring(0, maxLength - 3) + "..."; }} class SmsFormatter implements ContentFormatter { private static final int SMS_LIMIT = 160; private final String optOutSuffix; public SmsFormatter(String optOutSuffix) { this.optOutSuffix = optOutSuffix; } @Override public String format(String rawContent, FormattingContext context) { int availableLength = SMS_LIMIT - optOutSuffix.length(); String truncated = rawContent.length() > availableLength ? rawContent.substring(0, availableLength) : rawContent; return truncated + optOutSuffix; }} // DeliveryChannel implementationsclass SmtpDeliveryChannel implements DeliveryChannel { private final SmtpClient smtpClient; public SmtpDeliveryChannel(SmtpClient smtpClient) { this.smtpClient = smtpClient; } @Override public DeliveryResult deliver(FormattedNotification notification) { try { String messageId = smtpClient.send( notification.headers().get("to"), notification.headers().get("subject"), notification.content() ); return new DeliveryResult(true, messageId, null); } catch (SmtpException e) { return new DeliveryResult(false, null, e.getMessage()); } } @Override public boolean supportsRecipient(String recipient) { return recipient.contains("@"); // Simple email check }} class TwilioSmsChannel implements DeliveryChannel { private final TwilioClient twilioClient; public TwilioSmsChannel(TwilioClient twilioClient) { this.twilioClient = twilioClient; } @Override public DeliveryResult deliver(FormattedNotification notification) { try { String sid = twilioClient.sendSms( notification.headers().get("to"), notification.content() ); return new DeliveryResult(true, sid, null); } catch (TwilioException e) { return new DeliveryResult(false, null, e.getMessage()); } } @Override public boolean supportsRecipient(String recipient) { return recipient.startsWith("+"); // Phone number check }}Retry policies are excellent candidates for composition—they parameterize behavior that previously might have been scattered across subclasses or hardcoded.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// RetryPolicy implementations class NoRetryPolicy implements RetryPolicy { @Override public boolean shouldRetry(int attemptNumber, Exception lastError) { return false; } @Override public Duration getDelay(int attemptNumber) { return Duration.ZERO; }} class ExponentialBackoffRetryPolicy implements RetryPolicy { private final int maxRetries; private final Duration baseDelay; private final Set<Class<? extends Exception>> retryableExceptions; public ExponentialBackoffRetryPolicy( int maxRetries, Duration baseDelay, Set<Class<? extends Exception>> retryableExceptions) { this.maxRetries = maxRetries; this.baseDelay = baseDelay; this.retryableExceptions = retryableExceptions; } @Override public boolean shouldRetry(int attemptNumber, Exception lastError) { if (attemptNumber >= maxRetries) return false; return retryableExceptions.stream() .anyMatch(clazz -> clazz.isInstance(lastError)); } @Override public Duration getDelay(int attemptNumber) { // Exponential: 1s, 2s, 4s, 8s... long multiplier = (long) Math.pow(2, attemptNumber); return baseDelay.multipliedBy(multiplier); }} class ConstantDelayRetryPolicy implements RetryPolicy { private final int maxRetries; private final Duration delay; public ConstantDelayRetryPolicy(int maxRetries, Duration delay) { this.maxRetries = maxRetries; this.delay = delay; } @Override public boolean shouldRetry(int attemptNumber, Exception lastError) { return attemptNumber < maxRetries; } @Override public Duration getDelay(int attemptNumber) { return delay; }}Before integrating components, test them independently. This catches bugs early and builds confidence in each building block.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Unit tests for extracted components class SmsFormatterTest { @Test void format_longContent_truncatesAndAddsOptOut() { SmsFormatter formatter = new SmsFormatter(" STOP to opt-out"); String content = "A".repeat(160); // Too long String result = formatter.format(content, new FormattingContext("", Map.of())); assertEquals(160, result.length()); assertTrue(result.endsWith(" STOP to opt-out")); } @Test void format_shortContent_preservesContent() { SmsFormatter formatter = new SmsFormatter(" STOP"); String content = "Hello World"; String result = formatter.format(content, new FormattingContext("", Map.of())); assertEquals("Hello World STOP", result); }} class ExponentialBackoffRetryPolicyTest { @Test void shouldRetry_withinMaxRetries_returnsTrue() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 3, Duration.ofSeconds(1), Set.of(IOException.class) ); assertTrue(policy.shouldRetry(0, new IOException())); assertTrue(policy.shouldRetry(1, new IOException())); assertTrue(policy.shouldRetry(2, new IOException())); assertFalse(policy.shouldRetry(3, new IOException())); } @Test void getDelay_calculatesExponentialBackoff() { RetryPolicy policy = new ExponentialBackoffRetryPolicy( 5, Duration.ofSeconds(1), Set.of(Exception.class) ); assertEquals(Duration.ofSeconds(1), policy.getDelay(0)); assertEquals(Duration.ofSeconds(2), policy.getDelay(1)); assertEquals(Duration.ofSeconds(4), policy.getDelay(2)); assertEquals(Duration.ofSeconds(8), policy.getDelay(3)); }}This is the pivotal phase where inheritance begins transitioning to composition. The technique is introduce delegation—classes start holding references to components and forwarding calls to them, rather than inheriting behavior.
For safety, introduce delegation alongside existing inheritance rather than replacing it immediately:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Step 1: Add component fields alongside existing structure class EmailNotification extends Notification { // Existing fields private String subject; private List<Attachment> attachments; // NEW: Injected components private final ContentFormatter formatter; private final DeliveryChannel channel; private final NotificationValidator validator; // Transition constructor - accepts both old and new dependencies public EmailNotification( String recipient, String content, String subject, // New component dependencies ContentFormatter formatter, DeliveryChannel channel, NotificationValidator validator) { super(recipient, content); this.subject = subject; this.formatter = formatter; this.channel = channel; this.validator = validator; } // Step 2: Migrate methods to use delegation @Override public String formatContent() { // During transition, use feature flag to control behavior if (FeatureFlags.USE_NEW_FORMATTER) { return formatter.format(content, createContext()); } // Fall back to original implementation return originalFormatContent(); } @Override public void send() { if (FeatureFlags.USE_NEW_DELIVERY) { FormattedNotification notification = new FormattedNotification( formatContent(), Map.of("to", recipient, "subject", subject) ); DeliveryResult result = channel.deliver(notification); if (!result.success()) { throw new DeliveryException(result.error()); } } else { originalSend(); } } // Keep original implementations during transition private String originalFormatContent() { // Original HTML formatting logic return "<html><body>" + content + "</body></html>"; } private void originalSend() { // Original SMTP logic SmtpClient.getInstance().send(recipient, subject, formatContent()); } private FormattingContext createContext() { return new FormattingContext("email", Map.of("subject", subject)); }}To ensure delegation preserves behavior, implement parallel execution with comparison:
123456789101112131415161718192021222324
// Parallel execution to verify behavioral equivalence @Overridepublic String formatContent() { String oldResult = originalFormatContent(); String newResult = formatter.format(content, createContext()); if (FeatureFlags.PARALLEL_VERIFICATION) { if (!oldResult.equals(newResult)) { // Log discrepancy for investigation Logger.warn("Formatter mismatch: old='{}' new='{}'", oldResult, newResult); metrics.increment("formatter.mismatch"); // During verification phase, return old result return oldResult; } else { metrics.increment("formatter.match"); } } // Once verified, return new result return FeatureFlags.USE_NEW_FORMATTER ? newResult : oldResult;}Run the new delegation path in 'shadow mode' in production—execute both paths, compare results, but only use the old path's result. This catches discrepancies on real production data without impacting users.
Don't migrate all subclasses simultaneously. Instead:
This approach limits the blast radius if something goes wrong.
Once delegation is fully verified, you can remove the inheritance relationships. This is the highest-risk phase but should be straightforward if previous phases were done correctly.
For each subclass:
extends clause12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// BEFORE: Inheritance-based class EmailNotification extends Notification { private String subject; private final ContentFormatter formatter; private final DeliveryChannel channel; @Override public String formatContent() { return formatter.format(content, createContext()); } @Override public void send() { // delegation logic }} // AFTER: Composition-based (no inheritance) class EmailNotification implements Sendable { // State previously inherited now owned private final String recipient; private final String content; private final String subject; // Composed components private final ContentFormatter formatter; private final DeliveryChannel channel; private final NotificationValidator validator; private final RetryPolicy retryPolicy; public EmailNotification( String recipient, String content, String subject, ContentFormatter formatter, DeliveryChannel channel, NotificationValidator validator, RetryPolicy retryPolicy) { this.recipient = recipient; this.content = content; this.subject = subject; this.formatter = formatter; this.channel = channel; this.validator = validator; this.retryPolicy = retryPolicy; } @Override public void send() { // Validate ValidationResult validation = validator.validate( new NotificationData(recipient, content, Map.of("subject", subject)) ); if (!validation.valid()) { throw new InvalidNotificationException(validation.errors()); } // Format String formatted = formatter.format(content, createContext()); // Deliver with retry deliverWithRetry(new FormattedNotification( formatted, Map.of("to", recipient, "subject", subject) )); } private void deliverWithRetry(FormattedNotification notification) { int attempt = 0; Exception lastError = null; while (true) { try { DeliveryResult result = channel.deliver(notification); if (result.success()) return; throw new DeliveryException(result.error()); } catch (Exception e) { lastError = e; if (!retryPolicy.shouldRetry(attempt, e)) { throw new DeliveryException("Delivery failed after retries", e); } sleep(retryPolicy.getDelay(attempt)); attempt++; } } }} // The Sendable interface replaces the abstract Notification classinterface Sendable { void send();}With inheritance gone, object creation becomes more complex (more dependencies to inject). Introduce factories to encapsulate this complexity:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Factory encapsulates composition complexity class NotificationFactory { private final ContentFormatter emailFormatter; private final ContentFormatter smsFormatter; private final DeliveryChannel smtpChannel; private final DeliveryChannel twilioChannel; private final NotificationValidator validator; public NotificationFactory( TemplateEngine templateEngine, SmtpClient smtpClient, TwilioClient twilioClient) { this.emailFormatter = new HtmlEmailFormatter(templateEngine); this.smsFormatter = new SmsFormatter(" Reply STOP to unsubscribe"); this.smtpChannel = new SmtpDeliveryChannel(smtpClient); this.twilioChannel = new TwilioSmsChannel(twilioClient); this.validator = new CompositeValidator( new RecipientValidator(), new ContentLengthValidator() ); } public Sendable createEmailNotification( String recipient, String content, String subject) { return new EmailNotification( recipient, content, subject, emailFormatter, smtpChannel, validator, new ExponentialBackoffRetryPolicy(3, Duration.ofSeconds(1), Set.of(IOException.class)) ); } public Sendable createSmsNotification(String phoneNumber, String content) { return new SmsNotification( phoneNumber, content, smsFormatter, twilioChannel, validator, new ConstantDelayRetryPolicy(2, Duration.ofSeconds(5)) ); } public Sendable createTransactionalEmail( String recipient, String orderId, String content) { // Compose with specific components for transactional emails return new EmailNotification( recipient, content, "Order Confirmation: " + orderId, new TransactionalEmailFormatter(orderId), // Different formatter smtpChannel, validator, new NoRetryPolicy() // Transactional = no retry (idempotency concern) ); }}With inheritance eliminated, cleanup ensures the codebase is in its cleanest possible state.
Review the interfaces created during extraction. You may find:
Successful refactoring from inheritance to composition is a significant achievement. Document the improvement metrics: reduced bug rates, faster feature development, simpler code. Share the success with stakeholders to build support for future refactoring investments.
The next page covers maintaining behavioral correctness during refactoring—how to ensure the system behaves identically after transformation, techniques for regression prevention, and strategies for handling edge cases discovered during the process.