Separation of Concerns
Problem
Business logic, UI markup, styles, and data fetching are intertwined in single files, making it impossible to change styling without risking breaks in business rules or data handling. A single component mixes validation logic, API calls, state management, rendering logic, event handlers, and styling decisions all together, forcing developers to understand every aspect simultaneously to make any modification, while reusing business logic in different UI contexts requires copying and pasting code because the logic is tightly coupled to specific presentation components. Testing becomes difficult because tests must mock UI rendering, data fetching, and business rules together even when only testing one aspect.
Changing the data source from REST to GraphQL requires modifying components that shouldn’t care about data fetching implementation details, while styling changes require reading through business logic to find the right elements. Multiple developers cannot work on the same feature simultaneously because all concerns live in the same file, creating merge conflicts, while UI designers cannot iterate on presentation without understanding business logic and backend developers cannot modify data structures without coordinating with frontend presentation code. Simple changes ripple through the entire codebase because responsibilities are not isolated.
Solution
Divide code into distinct layers or modules where each handles a specific responsibility without depending on implementation details of other concerns. Separate data fetching into API or service layers that return normalized data structures. Extract business logic into pure functions or domain modules that operate on data without knowing about UI or data sources. Isolate presentation into components that receive data through props and emit events without containing business rules. Keep styling in separate CSS files, CSS modules, or styled-component definitions that don’t mix with logic. Use hooks, composables, or utilities to encapsulate reusable logic separately from components. Create clear boundaries between concerns so each layer can change independently. Data layer changes should not require UI changes if the contract stays the same. Business logic should work the same whether data comes from REST, GraphQL, or local storage. Presentation components should be testable by passing props without mocking data fetching. This separation reduces coupling, improves testability, enables parallel development, and makes the system easier to understand by allowing developers to focus on one concern at a time.
Example
This example demonstrates separating code into distinct layers for data fetching, business logic, and presentation, making each concern independent and easier to maintain, test, and reuse.
Basic Layer Separation
// ❌ MIXED CONCERNS: Everything in one place
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Data fetching mixed with component
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
// Business logic mixed with data fetching
if (data.age >= 18) {
setUser(data);
}
});
}, [userId]);
// Presentation mixed with inline styles
return (
<div style={{ padding: '20px', border: '1px solid #ccc' }}>
{user && <h1>{user.name}</h1>}
</div>
);
}
// ✅ SEPARATED CONCERNS: Each layer has a clear responsibility
// Data layer - handles API communication
const userApi = {
fetchUser: (id) =>
fetch(`/api/users/${id}`).then(r => r.json())
};
// Business logic layer - contains domain rules
const userValidation = {
isAdult: (user) => user.age >= 18,
canViewProfile: (user) => user.isActive && user.age >= 18
};
// Presentation layer - handles UI rendering only
function UserProfile({ user }) {
return (
<div className="user-profile">
<h1>{user.name}</h1>
<p>Age: {user.age}</p>
</div>
);
}
// Style layer - isolated in CSS file
// user-profile.css
// .user-profile { padding: 20px; border: 1px solid #ccc; }
// Container layer - coordinates between layers
function UserProfileContainer({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
userApi.fetchUser(userId).then(data => {
if (userValidation.canViewProfile(data)) {
setUser(data);
}
});
}, [userId]);
if (!user) return <div>Loading...</div>;
return <UserProfile user={user} />;
}
Separating Data Fetching from Presentation
// Data access layer - knows about API structure
class UserService {
async getUser(id) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Transform API response to domain model
return {
id: data.user_id,
name: data.full_name,
email: data.email_address,
age: data.age
};
}
async updateUser(id, updates) {
return fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
}
}
// Business logic layer - domain rules independent of data source
class UserValidator {
static validate(user) {
const errors = {};
if (!user.name || user.name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!user.email || !user.email.includes('@')) {
errors.email = 'Valid email required';
}
if (user.age < 18) {
errors.age = 'Must be 18 or older';
}
return {
isValid: Object.keys(errors).length === 0,
errors
};
}
}
// Presentation layer - pure UI with no business logic
function UserForm({ user, errors, onSubmit, onChange }) {
return (
<form onSubmit={onSubmit}>
<div>
<label>Name</label>
<input
value={user.name}
onChange={e => onChange('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label>Email</label>
<input
value={user.email}
onChange={e => onChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<button type="submit">Save</button>
</form>
);
}
// Container - orchestrates layers without mixing concerns
function UserFormContainer({ userId }) {
const [user, setUser] = useState({ name: '', email: '', age: 0 });
const [errors, setErrors] = useState({});
const userService = new UserService();
useEffect(() => {
userService.getUser(userId).then(setUser);
}, [userId]);
const handleChange = (field, value) => {
setUser(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const validation = UserValidator.validate(user);
if (!validation.isValid) {
setErrors(validation.errors);
return;
}
await userService.updateUser(userId, user);
};
return (
<UserForm
user={user}
errors={errors}
onSubmit={handleSubmit}
onChange={handleChange}
/>
);
}
Separating Business Logic from Framework Code
// ❌ MIXED: Business logic coupled to React hooks
function useShoppingCart() {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
const total = items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
return { items, addItem, total };
}
// ✅ SEPARATED: Business logic as pure functions
// Domain logic - framework-agnostic
class Cart {
constructor(items = []) {
this.items = items;
}
addItem(product) {
const existing = this.items.find(item => item.id === product.id);
if (existing) {
return new Cart(
this.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
);
}
return new Cart([...this.items, { ...product, quantity: 1 }]);
}
removeItem(productId) {
return new Cart(
this.items.filter(item => item.id !== productId)
);
}
getTotal() {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
}
getItemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
}
// Framework integration layer - thin adapter
function useShoppingCart() {
const [cart, setCart] = useState(() => new Cart());
const addItem = (product) => {
setCart(prevCart => prevCart.addItem(product));
};
const removeItem = (productId) => {
setCart(prevCart => prevCart.removeItem(productId));
};
return {
items: cart.items,
total: cart.getTotal(),
itemCount: cart.getItemCount(),
addItem,
removeItem
};
}
Separating Styles from Component Logic
// ❌ MIXED: Styles embedded in component logic
function Button({ onClick, children }) {
return (
<button
onClick={onClick}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{children}
</button>
);
}
// ✅ SEPARATED: Styles in dedicated CSS module
// Component imports CSS module for styling
import styles from './Button.module.css';
function Button({ onClick, children }) {
return (
<button onClick={onClick} className={styles.button}>
{children}
</button>
);
}
/* Button.module.css */
.button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.button:hover {
background-color: #0056b3;
}
## Benefits
- Makes code easier to understand by organizing it into logical sections where developers can focus on one concern at a time.
- Enables independent testing of logic, presentation, and data layers without complex mocking setups.
- Facilitates code reuse across different contexts since separated concerns can be imported independently.
- Reduces risk of unintended side effects when making changes since modifications are isolated to specific layers.
- Enables parallel development where different developers can work on presentation, logic, and data layers simultaneously.
- Allows swapping implementations at specific layers without cascading changes throughout the codebase.
- Makes code reviews more focused since reviewers can evaluate presentation, logic, or data changes independently.
## Tradeoffs
- Can lead to over-engineering if taken to extremes with too many abstraction layers for simple features.
- May require more files and boilerplate to maintain separation, increasing cognitive overhead for simple changes.
- Requires discipline and clear boundaries to maintain over time, which teams may struggle to enforce consistently.
- Without clear guidelines, developers may disagree on where specific logic belongs, leading to inconsistent patterns.
- Creates tension between separation and colocation, as related concerns live in different files.
- May complicate debugging when issues span multiple layers and require tracing through several files.
- Can result in numerous small files that make navigation harder without good tooling or naming conventions.
- Finding the right level of separation is difficult and varies based on application size and team experience.
- Pure separation is often impossible, as UI concerns naturally influence data requirements and vice versa.
- Testing may become more complex as integration tests must verify that separated layers work together correctly.
- Premature separation of concerns can make code harder to change when requirements are still evolving.
- Container components that orchestrate layers can become complex coordinators with their own maintenance burden.
- Strict separation can make rapid prototyping slower when exploring ideas that may not be kept.