Skip to main content

Domain Logic vs UI: The Core Principle

Domain logic is the code that would matter even if you deleted React tomorrow. It is business rules: validation, calculation, state transitions, and data transformations that exist because of your product's requirements, not because of how you happen to render things. UI logic is the code that translates those rules into pixels: event handling, styling, animation, form state, and component lifecycle. Clean architecture means putting domain logic in pure functions and classes that know nothing about React, and letting React consume those through thin adapter components.

The most common mistake is scattering domain logic across a dozen components and hooks. A validation rule lives in one component, a similar rule in another; business calculations are nested inside useEffect blocks; state machines are built from boolean flags. When a requirement changes, you end up patching logic in multiple places, and your tests are full of component mocks instead of direct assertions.

What Is Domain Logic?

Domain logic is code that encodes knowledge about your business. It answers questions like: "Is this email valid?" "What is the total price with tax?" "Can the user publish this post?" Domain logic is independent of how you implement the interface. It should work the same in a React app, a mobile app, a CLI tool, or a backend API.

Consider an e-commerce app. The rule "apply a 10% discount if the user has bought more than five times" is domain logic. The rule "this button should be disabled if the cart is empty" is UI logic. The first is a business decision that affects revenue; the second is a usability choice that affects the interface.

Examples of domain logic:

  • Validation: is this email/password/URL format correct?
  • Calculation: total cart price, tax, shipping cost, discount.
  • Business rules: can a user perform this action? Are prerequisites met?
  • State machines: order workflow (created → processing → shipped → delivered).
  • Data transformations: parse CSV, format dates, serialize objects.

Examples of UI logic:

  • Form state management: which fields have been touched? Are they focused?
  • Animations: fade-in on mount, slide when toggled.
  • Component lifecycle: cleanup timers, fetch on mount, re-render on state change.
  • Event handling: what happens when the user clicks, types, or submits?
  • Rendering conditions: show this element if X, hide if Y.

Extracting Domain Logic from Components

The easiest way to identify domain logic tangled in a component is to ask: "Could I test this without React?" If the answer is no, it's likely tightly coupled.

Consider a password-validation component:

export function PasswordField({ onValid }) {
const [password, setPassword] = useState('');
const [strength, setStrength] = useState('weak');
const [errors, setErrors] = useState([]);

const handleChange = (e) => {
const pwd = e.target.value;
setPassword(pwd);

// Domain logic tangled in the event handler
const errs = [];
if (pwd.length < 8) {
errs.push('Must be at least 8 characters');
}
if (!/[A-Z]/.test(pwd)) {
errs.push('Must contain uppercase letter');
}
if (!/[0-9]/.test(pwd)) {
errs.push('Must contain a number');
}

const isValid = errs.length === 0;
setErrors(errs);

if (isValid && /[!@#$%^&*]/.test(pwd)) {
setStrength('strong');
} else if (isValid) {
setStrength('medium');
} else {
setStrength('weak');
}

onValid(isValid);
};

return (
<div>
<input type="password" value={password} onChange={handleChange} />
<p>Strength: {strength}</p>
{errors.map((e) => (
<p key={e} style={{color: 'red'}}>{e}</p>
))}
</div>
);
}

Testing this requires rendering the component and simulating user input—fragile and slow. Extract the domain logic:

// Pure domain logic (no React)
export function validatePassword(password) {
const errors = [];
if (password.length < 8) {
errors.push('Must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Must contain uppercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Must contain a number');
}
return {
isValid: errors.length === 0,
errors,
};
}

export function getPasswordStrength(password, isValid) {
if (!isValid) return 'weak';
return /[!@#$%^&*]/.test(password) ? 'strong' : 'medium';
}

Now test directly:

describe('validatePassword', () => {
it('rejects passwords shorter than 8 characters', () => {
const result = validatePassword('Short1');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Must be at least 8 characters');
});

it('accepts strong passwords', () => {
const result = validatePassword('MyPass123!');
expect(result.isValid).toBe(true);
});
});

describe('getPasswordStrength', () => {
it('returns strong if password contains special character', () => {
expect(getPasswordStrength('MyPass123!', true)).toBe('strong');
});
});

The component is now thin:

export function PasswordField({ onValid }) {
const [password, setPassword] = useState('');
const validation = validatePassword(password);
const strength = getPasswordStrength(password, validation.isValid);

const handleChange = (e) => {
setPassword(e.target.value);
onValid(validation.isValid);
};

return (
<div>
<input type="password" value={password} onChange={handleChange} />
<p>Strength: {strength}</p>
{validation.errors.map((e) => (
<p key={e} style={{color: 'red'}}>{e}</p>
))}
</div>
);
}

The Dependency Graph

Clean architecture maintains a strict dependency direction: domain logic has zero dependencies on UI; UI depends on domain logic. Never import from components into domain files.

┌──────────────────┐
│ React Component │
└──────────────────┘
↑ (depends on)
┌──────────────────┐
│ Use Cases │
└──────────────────┘
↑ (depends on)
┌──────────────────┐
│ Domain Logic │
└──────────────────┘
↑ (depends on)
┌──────────────────┐
│ No Dependencies │
│ (pure JS/TS) │
└──────────────────┘

If a domain file ever imports from '../components' or 'react', you have an architecture violation. Use a linter rule to prevent this:

// .eslintrc.js example (mock)
rules: {
'no-restricted-imports': [
'error',
{
patterns: ['domains/**/components', 'react', 'react-dom']
}
]
}

Value Objects and Entities

Domain logic is often best expressed as classes that encapsulate invariants. A Money class ensures you never accidentally compare dollars to euros; a User class ensures a user always has a valid email.

// Domain logic: Money is always in a single currency
export class Money {
constructor(amount, currency) {
if (amount < 0) {
throw new Error('Amount cannot be negative');
}
this.amount = amount;
this.currency = currency;
}

add(other) {
if (other.currency !== this.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}

applyTax(rate) {
return new Money(this.amount * (1 + rate), this.currency);
}
}

// Test it directly
const price = new Money(100, 'USD');
const withTax = price.applyTax(0.1);
expect(withTax.amount).toBe(110);

This class enforces invariants at the domain level, not at the UI level. You cannot accidentally create a Money with a negative amount, and you cannot add currencies that don't match. React components consume these objects but don't define them.

Key Takeaways

  • Domain logic is business rules: validation, calculation, state transitions. UI logic is how to render and interact with those rules.
  • Extract domain logic into pure functions and classes that have zero dependencies on React.
  • Test domain logic directly without rendering components; unit tests are fast and reliable.
  • Maintain strict dependency direction: components depend on domain logic, never the reverse.
  • Use value objects (Money, User, Email) to encode invariants at the domain level.

Frequently Asked Questions

How much logic should I extract into pure functions?

Any logic that would be useful in multiple places or hard to test in a component. A rule of thumb: if you use the same validation in three forms, extract it. If a calculation takes more than three lines, extract it.

Is it over-engineering to create classes like Money for simple values?

For a simple blog, maybe. For an app that handles money, taxation, or compliance, no—encoding invariants at the type level catches bugs at development time, not production. The time you invest upfront saves debugging and customer support later.

What if my domain logic depends on the API response structure?

Adapters handle that. Your domain classes represent the concept as your business thinks about it. An adapter translates between the API response and your domain class. If the API changes, only the adapter changes; domain logic is untouched.

Can I put async logic in domain code?

Generally, no. Domain logic is synchronous and deterministic. Async operations (HTTP, database) are adapters. A use case orchestrates domain logic and adapters: call domain validators, then call an adapter to persist, then return the result.

Further Reading