Separation of Concerns
TL;DR: Separation of Concerns means each class handles one responsibility. The Law of Demeter means a method only talks to its immediate friends -- not to strangers accessed through a chain of getters. Together they produce designs where classes are independent, testable, and easy to change without ripple effects.

Separation of Concerns
Separation of Concerns says that different responsibilities belong in different classes. When a single class handles display, input, and business logic, changing any one of those things risks breaking the other two.
This sounds obvious, but it's one of the most common mistakes in LLD interviews. Candidates cram everything into a single Game or System class because it feels faster. It isn't -- it leads to a tangled mess that's hard to explain, hard to extend, and hard for the interviewer to follow.
Example: Tic-Tac-Toe
// BAD: One class does everything
class TicTacToe {
private char[][] board = new char[3][3];
private Scanner scanner = new Scanner(System.in);
public void play() {
while (true) {
// Display logic
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
System.out.print(board[i][j] == '\0' ? "." : board[i][j]);
if (j < 2) System.out.print(" | ");
}
System.out.println();
}
// Input handling
System.out.print("Enter row and col: ");
int row = scanner.nextInt();
int col = scanner.nextInt();
// Game rules
if (board[row][col] != '\0') {
System.out.println("Spot taken!");
continue;
}
board[row][col] = 'X';
// Win checking
// ... 30 more lines of win-check logic mixed in here
}
}
}
This class knows how to render the board, read user input, validate moves, check wins, and manage turns. If the interviewer asks "how would you support a GUI instead of console output?" -- you have to rewrite the entire class.
// GOOD: Each class has one job
class Board {
private final char[][] grid = new char[3][3];
public boolean placeMove(int row, int col, char piece) {
if (grid[row][col] != '\0') return false;
grid[row][col] = piece;
return true;
}
public boolean hasWon(char piece) {
// Check rows, columns, diagonals
for (int i = 0; i < 3; i++) {
if (grid[i][0] == piece && grid[i][1] == piece && grid[i][2] == piece) return true;
if (grid[0][i] == piece && grid[1][i] == piece && grid[2][i] == piece) return true;
}
return (grid[0][0] == piece && grid[1][1] == piece && grid[2][2] == piece)
|| (grid[0][2] == piece && grid[1][1] == piece && grid[2][0] == piece);
}
public char getCell(int row, int col) { return grid[row][col]; }
public boolean isFull() {
for (char[] row : grid)
for (char c : row)
if (c == '\0') return false;
return true;
}
}
class ConsoleDisplay {
public void renderBoard(Board board) {
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
char c = board.getCell(i, j);
System.out.print(c == '\0' ? "." : c);
if (j < 2) System.out.print(" | ");
}
System.out.println();
}
}
public void showMessage(String message) {
System.out.println(message);
}
}
class ConsoleInputHandler {
private final Scanner scanner = new Scanner(System.in);
public int[] getMove() {
System.out.print("Enter row and col: ");
return new int[]{scanner.nextInt(), scanner.nextInt()};
}
}
class GameController {
private final Board board;
private final ConsoleDisplay display;
private final ConsoleInputHandler input;
public GameController(Board board, ConsoleDisplay display, ConsoleInputHandler input) {
this.board = board;
this.display = display;
this.input = input;
}
public void play() {
char currentPiece = 'X';
while (!board.isFull()) {
display.renderBoard(board);
int[] move = input.getMove();
if (!board.placeMove(move[0], move[1], currentPiece)) {
display.showMessage("Spot taken!");
continue;
}
if (board.hasWon(currentPiece)) {
display.renderBoard(board);
display.showMessage(currentPiece + " wins!");
return;
}
currentPiece = (currentPiece == 'X') ? 'O' : 'X';
}
display.showMessage("Draw!");
}
}
// GOOD: C++ equivalent
class Board {
char grid[3][3] = {};
public:
bool placeMove(int row, int col, char piece) {
if (grid[row][col] != '\0') return false;
grid[row][col] = piece;
return true;
}
bool hasWon(char piece) const {
for (int i = 0; i < 3; i++) {
if (grid[i][0] == piece && grid[i][1] == piece && grid[i][2] == piece) return true;
if (grid[0][i] == piece && grid[1][i] == piece && grid[2][i] == piece) return true;
}
return (grid[0][0] == piece && grid[1][1] == piece && grid[2][2] == piece)
|| (grid[0][2] == piece && grid[1][1] == piece && grid[2][0] == piece);
}
char getCell(int row, int col) const { return grid[row][col]; }
bool isFull() const {
for (const auto& row : grid)
for (char c : row)
if (c == '\0') return false;
return true;
}
};
class ConsoleDisplay {
public:
void renderBoard(const Board& board) const {
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
char c = board.getCell(i, j);
std::cout << (c == '\0' ? '.' : c);
if (j < 2) std::cout << " | ";
}
std::cout << '\n';
}
}
void showMessage(const std::string& message) const {
std::cout << message << '\n';
}
};
class ConsoleInputHandler {
public:
std::pair<int, int> getMove() const {
std::cout << "Enter row and col: ";
int row, col;
std::cin >> row >> col;
return {row, col};
}
};
# GOOD: Python equivalent
class Board:
def __init__(self):
self._grid = [['' for _ in range(3)] for _ in range(3)]
def place_move(self, row: int, col: int, piece: str) -> bool:
if self._grid[row][col] != '':
return False
self._grid[row][col] = piece
return True
def has_won(self, piece: str) -> bool:
g = self._grid
for i in range(3):
if all(g[i][j] == piece for j in range(3)):
return True
if all(g[j][i] == piece for j in range(3)):
return True
return (all(g[i][i] == piece for i in range(3))
or all(g[i][2 - i] == piece for i in range(3)))
def get_cell(self, row: int, col: int) -> str:
return self._grid[row][col]
def is_full(self) -> bool:
return all(self._grid[i][j] != ''
for i in range(3) for j in range(3))
class ConsoleDisplay:
def render_board(self, board: Board) -> None:
for i in range(3):
row_str = " | ".join(
board.get_cell(i, j) or "." for j in range(3)
)
print(row_str)
def show_message(self, message: str) -> None:
print(message)
class ConsoleInputHandler:
def get_move(self) -> tuple[int, int]:
raw = input("Enter row and col: ").split()
return int(raw[0]), int(raw[1])
Now swapping ConsoleDisplay for a GUIDisplay requires zero changes to Board or GameController. Each class can be tested independently. The interviewer can clearly see your design because each responsibility is in its own box.
Interview tip: When you sketch your class diagram, ask yourself: "If I need to change how the board is displayed, how many classes do I touch?" If the answer is more than one, your concerns are not separated.
Law of Demeter: Only Talk to Your Friends
The Law of Demeter says: a method should only call methods on objects it directly knows about. It should not reach through one object to get another object to call a method on that object.
In plain terms: don't chain getters across different types.
The Problem: Method Chaining Across Types
// BAD: Reaching through multiple objects
class OrderProcessor {
public String getCustomerZipCode(Order order) {
return order.getCustomer().getAddress().getZipCode();
}
public boolean isEligibleForFreeShipping(Order order) {
String state = order.getCustomer().getAddress().getState();
return state.equals("CA") || state.equals("NY");
}
}
// BAD: C++ equivalent
class OrderProcessor {
public:
std::string getCustomerZipCode(const Order& order) const {
return order.getCustomer().getAddress().getZipCode();
}
bool isEligibleForFreeShipping(const Order& order) const {
std::string state = order.getCustomer().getAddress().getState();
return state == "CA" || state == "NY";
}
};
# BAD: Python equivalent
class OrderProcessor:
def get_customer_zip_code(self, order: Order) -> str:
return order.get_customer().get_address().get_zip_code()
def is_eligible_for_free_shipping(self, order: Order) -> bool:
state = order.get_customer().get_address().get_state()
return state in ("CA", "NY")
This code knows that an Order has a Customer, that a Customer has an Address, and that an Address has a zipCode and state. If any of those internal structures change -- say, Customer starts storing multiple addresses -- every method chain like this breaks.
The chain order.getCustomer().getAddress().getZipCode() exposes three levels of internal structure. OrderProcessor now depends on the internals of Order, Customer, AND Address.
The Fix: Delegate Through Your Immediate Neighbor
// GOOD: Each class delegates to its direct neighbor
class Order {
private Customer customer;
// ... other fields
public String getCustomerZipCode() {
return customer.getZipCode();
}
public String getCustomerState() {
return customer.getState();
}
}
class Customer {
private Address address;
public String getZipCode() {
return address.getZipCode();
}
public String getState() {
return address.getState();
}
}
class Address {
private String zipCode;
private String state;
public String getZipCode() { return zipCode; }
public String getState() { return state; }
}
// Now OrderProcessor only talks to Order
class OrderProcessor {
public String getCustomerZipCode(Order order) {
return order.getCustomerZipCode();
}
public boolean isEligibleForFreeShipping(Order order) {
String state = order.getCustomerState();
return state.equals("CA") || state.equals("NY");
}
}
// GOOD: C++ equivalent
class Address {
std::string zipCode;
std::string state;
public:
Address(std::string zip, std::string st)
: zipCode(std::move(zip)), state(std::move(st)) {}
const std::string& getZipCode() const { return zipCode; }
const std::string& getState() const { return state; }
};
class Customer {
Address address;
public:
Customer(Address addr) : address(std::move(addr)) {}
std::string getZipCode() const { return address.getZipCode(); }
std::string getState() const { return address.getState(); }
};
class Order {
Customer customer;
public:
Order(Customer cust) : customer(std::move(cust)) {}
std::string getCustomerZipCode() const { return customer.getZipCode(); }
std::string getCustomerState() const { return customer.getState(); }
};
class OrderProcessor {
public:
std::string getCustomerZipCode(const Order& order) const {
return order.getCustomerZipCode();
}
bool isEligibleForFreeShipping(const Order& order) const {
std::string state = order.getCustomerState();
return state == "CA" || state == "NY";
}
};
# GOOD: Python equivalent
class Address:
def __init__(self, zip_code: str, state: str):
self._zip_code = zip_code
self._state = state
def get_zip_code(self) -> str:
return self._zip_code
def get_state(self) -> str:
return self._state
class Customer:
def __init__(self, address: Address):
self._address = address
def get_zip_code(self) -> str:
return self._address.get_zip_code()
def get_state(self) -> str:
return self._address.get_state()
class Order:
def __init__(self, customer: Customer):
self._customer = customer
def get_customer_zip_code(self) -> str:
return self._customer.get_zip_code()
def get_customer_state(self) -> str:
return self._customer.get_state()
class OrderProcessor:
def get_customer_zip_code(self, order: Order) -> str:
return order.get_customer_zip_code()
def is_eligible_for_free_shipping(self, order: Order) -> bool:
return order.get_customer_state() in ("CA", "NY")
Now OrderProcessor only knows about Order. If Customer changes how it stores addresses (one address vs. multiple), only Customer and Order need to update. OrderProcessor is untouched.
When Method Chaining Is Fine: Fluent Builders
Not all method chaining violates the Law of Demeter. The rule is about chaining across different types. Fluent APIs that return the same object are fine.
// FINE: Fluent builder returns the same type (Builder) each time
User user = User.builder()
.setName("Alice") // returns Builder
.setEmail("a@b.com") // returns Builder
.setAge(30) // returns Builder
.build(); // returns User
// FINE: C++ fluent builder
User user = User::Builder()
.setName("Alice")
.setEmail("a@b.com")
.setAge(30)
.build();
# FINE: Python fluent builder
user = (UserBuilder()
.set_name("Alice")
.set_email("a@b.com")
.set_age(30)
.build())
Each call in this chain returns the same Builder object. You're not reaching through one object to get a different type of object. The Law of Demeter is about structural coupling between different types, not about chaining calls on the same instance.
Similarly, stream/collection operations are fine:
// FINE: Stream operations on the same pipeline
List<String> names = orders.stream()
.filter(o -> o.getTotal() > 100)
.map(Order::getCustomerName)
.collect(Collectors.toList());
The rule: if each call in the chain returns the same type (or a closely related pipeline type), it's a fluent API. If each call returns a different domain object, you're violating Demeter.
Separation of Concerns + Law of Demeter Together
These two principles reinforce each other:
- Separation of Concerns tells you to put each responsibility in its own class.
- Law of Demeter tells you to interact with those classes through their public interfaces, not by reaching into their internals.
When you separate concerns properly, each class has a clear, narrow interface. When you follow the Law of Demeter, classes communicate through those interfaces without knowledge of internal structure.
The result is a design where changing one class doesn't cascade through the entire system.
Interview tip: When the interviewer says "walk me through how this operation works," you should be able to trace the flow through 2-3 classes with clean handoffs. If you're describing a method that calls into 5 different objects to gather data from their internals, your design has coupling problems.
Quick Recap
| Principle | Rule | Interview application |
|---|---|---|
| Separation of Concerns | Each class handles one responsibility | Split display, input, game logic, and storage into separate classes |
| God class | A single class doing everything | Break it up -- interviewers penalize monolithic designs |
| Law of Demeter | Only talk to your immediate friends | Use order.getCustomerZipCode() not order.getCustomer().getAddress().getZipCode() |
| Fluent builders | Chaining on the same type is fine | builder.setName().setAge().build() does not violate Demeter |
| Testing benefit | Separated concerns are independently testable | You can test Board without ConsoleDisplay -- the interviewer notices this |