Clean Code Chapter 3: Functions
Writing Functions That Tell a Story
In Chapter 3 of Clean Code, Robert C. Martin lays out a compelling vision: functions should be small, do one thing, and read like well-written prose. This chapter transformed how I think about structuring code, and the principles apply beautifully to both Rails backends and React frontends.
Let's explore the key ideas with practical examples.
Small!
Martin's first rule of functions is that they should be small. His second rule? They should be smaller than that.
Functions should rarely be more than 20 lines. Ideally, they should be 5-10 lines. This isn't arbitrary. Small functions are easier to understand, test, and maintain.
Rails Example
Before: A bloated controller action
def create
@user = User.new(user_params)
if @user.save
if params[:subscribe_to_newsletter]
newsletter = Newsletter.find_by(name: 'weekly')
if newsletter
Subscription.create(user: @user, newsletter: newsletter)
NewsletterMailer.welcome_email(@user).deliver_later
end
end
if @user.referral_code.present?
referrer = User.find_by(referral_code: @user.referral_code)
if referrer
Credit.create(user: referrer, amount: 10, reason: 'referral')
Credit.create(user: @user, amount: 5, reason: 'referred')
ReferralMailer.successful_referral(referrer, @user).deliver_later
end
end
redirect_to dashboard_path, notice: 'Welcome!'
else
render :new
end
end
After: Small, focused functions
def create
@user = User.new(user_params)
if @user.save
handle_newsletter_subscription
process_referral
redirect_to dashboard_path, notice: 'Welcome!'
else
render :new
end
end
private
def handle_newsletter_subscription
return unless params[:subscribe_to_newsletter]
NewsletterSubscriptionService.subscribe(@user, 'weekly')
end
def process_referral
return unless @user.referral_code.present?
ReferralService.process(@user, @user.referral_code)
end
React Example
Before: A component doing too much
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
return fetch(`/api/users/${userId}/orders`);
})
.then(res => res.json())
.then(data => {
setOrders(data);
return fetch(`/api/users/${userId}/notifications`);
})
.then(res => res.json())
.then(data => {
setNotifications(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return (
<div>
<h1>Welcome, {user?.name}</h1>
<div className="stats">
<div>Orders: {orders.length}</div>
<div>Unread: {notifications.filter(n => !n.read).length}</div>
</div>
<ul>
{orders.map(order => (
<li key={order.id}>
Order #{order.id} - ${order.total} - {order.status}
</li>
))}
</ul>
</div>
);
}
After: Composed from smaller pieces
function UserDashboard({ userId }: { userId: string }) {
const { user, orders, notifications, loading } = useDashboardData(userId);
if (loading) return <Spinner />;
return (
<div>
<WelcomeHeader userName={user?.name} />
<DashboardStats orderCount={orders.length} notifications={notifications} />
<OrderList orders={orders} />
</div>
);
}
function DashboardStats({
orderCount,
notifications
}: {
orderCount: number;
notifications: Notification[];
}) {
const unreadCount = notifications.filter(n => !n.read).length;
return (
<div className="stats">
<div>Orders: {orderCount}</div>
<div>Unread: {unreadCount}</div>
</div>
);
}
Do One Thing
Functions should do one thing. They should do it well. They should do it only.
But how do you know if a function does "one thing"? Martin offers a useful test: if you can extract another function from it with a name that isn't merely a restatement of its implementation, it's doing more than one thing.
Rails Example
Before: A method doing multiple things
def process_order(order)
# Validate inventory
order.line_items.each do |item|
product = item.product
if product.inventory_count < item.quantity
raise InsufficientInventoryError, "Not enough #{product.name}"
end
end
# Calculate totals
subtotal = order.line_items.sum { |item| item.price * item.quantity }
tax = subtotal * order.tax_rate
shipping = calculate_shipping(order)
total = subtotal + tax + shipping
# Update order
order.update!(
subtotal: subtotal,
tax: tax,
shipping: shipping,
total: total,
status: 'confirmed'
)
# Send notifications
OrderMailer.confirmation(order).deliver_later
InventoryService.reserve_items(order)
end
After: Each function does one thing
def process_order(order)
validate_inventory(order)
finalize_totals(order)
confirm_order(order)
send_notifications(order)
end
private
def validate_inventory(order)
InventoryValidator.validate!(order)
end
def finalize_totals(order)
OrderTotalCalculator.calculate!(order)
end
def confirm_order(order)
order.update!(status: 'confirmed')
end
def send_notifications(order)
OrderNotifier.send_confirmation(order)
InventoryService.reserve_items(order)
end
React Example
Before: A handler doing multiple things
function handleSubmit(event: FormEvent) {
event.preventDefault();
// Validation
const errors: string[] = [];
if (!formData.email.includes('@')) {
errors.push('Invalid email');
}
if (formData.password.length < 8) {
errors.push('Password too short');
}
if (formData.password !== formData.confirmPassword) {
errors.push('Passwords do not match');
}
if (errors.length > 0) {
setErrors(errors);
return;
}
// Transform data
const payload = {
email: formData.email.toLowerCase().trim(),
password: formData.password,
marketingOptIn: formData.newsletter,
};
// Submit
setSubmitting(true);
fetch('/api/register', {
method: 'POST',
body: JSON.stringify(payload),
})
.then(res => res.json())
.then(data => {
localStorage.setItem('token', data.token);
navigate('/dashboard');
})
.catch(err => setErrors([err.message]))
.finally(() => setSubmitting(false));
}
After: Separated concerns
function handleSubmit(event: FormEvent) {
event.preventDefault();
const validationErrors = validateRegistrationForm(formData);
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
submitRegistration(formData);
}
function validateRegistrationForm(data: RegistrationFormData): string[] {
const errors: string[] = [];
if (!isValidEmail(data.email)) errors.push('Invalid email');
if (!isValidPassword(data.password)) errors.push('Password too short');
if (!passwordsMatch(data.password, data.confirmPassword)) {
errors.push('Passwords do not match');
}
return errors;
}
async function submitRegistration(data: RegistrationFormData) {
setSubmitting(true);
try {
const payload = buildRegistrationPayload(data);
const response = await registerUser(payload);
handleSuccessfulRegistration(response);
} catch (err) {
setErrors([err.message]);
} finally {
setSubmitting(false);
}
}
One Level of Abstraction per Function
Functions should maintain a consistent level of abstraction. Mixing high-level concepts with low-level details creates cognitive dissonance.
Rails Example
Before: Mixed abstraction levels
def onboard_new_customer(customer_params)
# High level: create customer
customer = Customer.create!(customer_params)
# Low level: SQL for finding default plan
default_plan = Plan.where(active: true)
.where('price_cents > 0')
.order(:price_cents)
.first
# High level: create subscription
subscription = customer.subscriptions.create!(plan: default_plan)
# Low level: date calculation
trial_end = Time.current + 14.days
trial_end = trial_end.end_of_day
trial_end = trial_end.in_time_zone(customer.timezone)
subscription.update!(trial_ends_at: trial_end)
# High level: send welcome
CustomerMailer.welcome(customer).deliver_later
end
After: Consistent abstraction
def onboard_new_customer(customer_params)
customer = create_customer(customer_params)
start_trial_subscription(customer)
send_welcome_email(customer)
customer
end
private
def create_customer(params)
Customer.create!(params)
end
def start_trial_subscription(customer)
plan = Plan.default_starter_plan
trial_end = TrialPeriod.calculate_end_date(customer.timezone)
customer.subscriptions.create!(
plan: plan,
trial_ends_at: trial_end
)
end
def send_welcome_email(customer)
CustomerMailer.welcome(customer).deliver_later
end
React Example
Before: Mixed abstractions in a component
function CheckoutPage() {
const cart = useCart();
// High level
const handleCheckout = async () => {
// Low level: manual localStorage manipulation
const savedAddress = localStorage.getItem('shipping_address');
const address = savedAddress ? JSON.parse(savedAddress) : null;
// Low level: manual API construction
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
},
body: JSON.stringify({
items: cart.items.map(i => ({ id: i.productId, qty: i.quantity })),
shipping: address,
}),
});
// High level
if (response.ok) {
cart.clear();
navigate('/confirmation');
}
};
return <CheckoutForm onSubmit={handleCheckout} />;
}
After: Consistent high-level abstraction
function CheckoutPage() {
const cart = useCart();
const { createOrder } = useOrders();
const { shippingAddress } = useShippingAddress();
const handleCheckout = async () => {
const order = await createOrder({
items: cart.items,
shippingAddress,
});
if (order) {
cart.clear();
navigate('/confirmation');
}
};
return <CheckoutForm onSubmit={handleCheckout} />;
}
Use Descriptive Names
Don't be afraid to make a name long. A long descriptive name is better than a short enigmatic name.
The name should describe what the function does. If you struggle to name it, that's a sign it might be doing too much.
Rails Example
Before: Vague names
def process(user)
# What does this process?
end
def handle_data(params)
# Handle how?
end
def do_stuff(order)
# Very helpful...
end
def check(item)
# Check what exactly?
end
After: Names that explain intent
def send_password_reset_email(user)
end
def normalize_address_params(params)
end
def apply_promotional_discount(order)
end
def inventory_sufficient_for?(item)
end
React Example
Before: Unclear names
function handle(e) { }
function update(data) { }
function process(items) { }
function getData() { }
function Component1() { }
After: Self-documenting names
function handleEmailChange(event: ChangeEvent<HTMLInputElement>) { }
function updateUserProfile(profileData: ProfileUpdate) { }
function calculateCartSubtotal(lineItems: CartItem[]) { }
function fetchActiveSubscriptions() { }
function SubscriptionPlanSelector() { }
Function Arguments
The ideal number of arguments is zero. Next comes one, then two. Three arguments should be avoided where possible. More than three requires special justification.
Rails Example
Before: Too many arguments
def create_shipment(order_id, carrier, service_level, weight, dimensions,
signature_required, insurance_amount, saturday_delivery)
# ...
end
# Calling code
create_shipment(
order.id, 'fedex', 'overnight', 2.5, [10, 8, 4],
true, 500.00, false
)
After: Using an argument object
def create_shipment(shipment_request)
carrier = shipment_request.carrier
# ...
end
# With a value object or struct
shipment_request = ShipmentRequest.new(
order: order,
carrier: 'fedex',
service_level: 'overnight',
package: Package.new(weight: 2.5, dimensions: [10, 8, 4]),
options: ShippingOptions.new(
signature_required: true,
insurance_amount: 500.00,
saturday_delivery: false
)
)
create_shipment(shipment_request)
React Example
Before: Props explosion
function ProductCard({
name,
price,
originalPrice,
imageUrl,
rating,
reviewCount,
inStock,
onAddToCart,
onAddToWishlist,
onQuickView,
showRating,
showWishlist,
variant,
}: ProductCardProps) {
// 13 props!
}
After: Grouped into logical objects
interface ProductCardProps {
product: Product;
actions: ProductActions;
display?: DisplayOptions;
}
function ProductCard({ product, actions, display = defaultDisplay }: ProductCardProps) {
const { name, price, imageUrl } = product;
const { onAddToCart, onAddToWishlist } = actions;
const { showRating, variant } = display;
// Much cleaner
}
// Usage
<ProductCard
product={product}
actions={{ onAddToCart, onAddToWishlist, onQuickView }}
display={{ showRating: true, variant: 'compact' }}
/>
Have No Side Effects
Side effects are lies. Your function promises to do one thing, but it also does other hidden things.
Rails Example
Before: Hidden side effect
def authenticate(username, password)
user = User.find_by(username: username)
return false unless user
if user.valid_password?(password)
# Hidden side effect! The method name suggests it only authenticates
session[:user_id] = user.id
user.update!(last_login_at: Time.current)
true
else
false
end
end
After: Explicit about what it does
def authenticate(username, password)
user = User.find_by(username: username)
return nil unless user&.valid_password?(password)
user
end
def sign_in(user)
session[:user_id] = user.id
record_login(user)
end
def record_login(user)
user.update!(last_login_at: Time.current)
end
# Usage is now explicit
if user = authenticate(username, password)
sign_in(user)
redirect_to dashboard_path
end
React Example
Before: Sneaky side effects
function formatCurrency(amount: number): string {
// Side effect: logging
console.log(`Formatting: ${amount}`);
// Side effect: analytics
analytics.track('currency_formatted', { amount });
// Side effect: caching in global state
window.__lastFormattedAmount = amount;
return `$${amount.toFixed(2)}`;
}
After: Pure function
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
// If you need tracking, make it explicit
function formatAndTrackCurrency(amount: number): string {
analytics.track('currency_formatted', { amount });
return formatCurrency(amount);
}
Command Query Separation
Functions should either do something (command) or answer something (query), but not both.
Rails Example
Before: Mixed command and query
def set_and_return_status(order, new_status)
order.update!(status: new_status)
order.status # Returns something
end
# Confusing usage
if set_and_return_status(order, 'shipped') == 'shipped'
# Did it change? Was it already shipped?
end
After: Separate command and query
# Command: changes state, returns nothing meaningful
def update_status(order, new_status)
order.update!(status: new_status)
end
# Query: returns information, changes nothing
def current_status(order)
order.status
end
# Clear usage
update_status(order, 'shipped')
if current_status(order) == 'shipped'
notify_customer(order)
end
React Example
Before: Mutation returns value
function toggleAndGetVisibility(): boolean {
setIsVisible(prev => !prev);
return !isVisible; // This might not be what you expect due to async state
}
After: Separated
// Command
function toggleVisibility(): void {
setIsVisible(prev => !prev);
}
// Query (derived state)
const isCurrentlyVisible = isVisible;
// Or use a callback pattern if you need the new value
function toggleVisibility(onToggled?: (newValue: boolean) => void): void {
setIsVisible(prev => {
const newValue = !prev;
onToggled?.(newValue);
return newValue;
});
}
Prefer Exceptions to Returning Error Codes
Returning error codes means the caller must deal with the error immediately. Exceptions let you separate the happy path from error handling.
Rails Example
Before: Error codes
def create_account(params)
return :invalid_email unless valid_email?(params[:email])
return :email_taken if User.exists?(email: params[:email])
return :weak_password unless strong_password?(params[:password])
user = User.create!(params)
:success
end
# Calling code becomes a mess
result = create_account(params)
case result
when :success then redirect_to dashboard_path
when :invalid_email then flash[:error] = "Invalid email"
when :email_taken then flash[:error] = "Email already registered"
when :weak_password then flash[:error] = "Password too weak"
end
After: Exceptions
def create_account(params)
validate_email!(params[:email])
ensure_email_available!(params[:email])
validate_password_strength!(params[:password])
User.create!(params)
end
# Clean calling code
begin
create_account(params)
redirect_to dashboard_path
rescue InvalidEmailError => e
flash[:error] = e.message
render :new
rescue EmailTakenError => e
flash[:error] = "Email already registered"
render :new
rescue WeakPasswordError => e
flash[:error] = e.message
render :new
end
React/TypeScript Example
Before: Error returns
type Result = { success: true; data: User } | { success: false; error: string };
async function fetchUser(id: string): Promise<Result> {
try {
const response = await api.get(`/users/${id}`);
return { success: true, data: response.data };
} catch {
return { success: false, error: 'Failed to fetch user' };
}
}
// Awkward usage
const result = await fetchUser(id);
if (result.success) {
setUser(result.data);
} else {
setError(result.error);
}
After: Let exceptions flow (with proper boundaries)
async function fetchUser(id: string): Promise<User> {
const response = await api.get(`/users/${id}`);
return response.data;
}
// With an error boundary or try/catch at the appropriate level
function UserProfile({ userId }: { userId: string }) {
const { data: user, error, isLoading } = useQuery(
['user', userId],
() => fetchUser(userId)
);
if (error) return <ErrorDisplay error={error} />;
if (isLoading) return <Spinner />;
return <ProfileCard user={user} />;
}
Don't Repeat Yourself (DRY)
Duplication is the root of all evil in software. Every piece of knowledge should have a single, unambiguous representation in the system.
Rails Example
Before: Duplicated logic
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
# Calculate total
subtotal = @order.line_items.sum { |i| i.price * i.quantity }
discount = @order.coupon&.calculate_discount(subtotal) || 0
tax = (subtotal - discount) * 0.08
@order.total = subtotal - discount + tax
@order.save!
end
end
class Order < ApplicationRecord
def recalculate_total
# Same logic repeated!
subtotal = line_items.sum { |i| i.price * i.quantity }
discount = coupon&.calculate_discount(subtotal) || 0
tax = (subtotal - discount) * 0.08
self.total = subtotal - discount + tax
save!
end
end
After: Single source of truth
class OrderTotalCalculator
def initialize(order)
@order = order
end
def calculate
subtotal - discount + tax
end
def subtotal
@order.line_items.sum { |i| i.price * i.quantity }
end
def discount
@order.coupon&.calculate_discount(subtotal) || 0
end
def tax
(subtotal - discount) * TAX_RATE
end
private
TAX_RATE = 0.08
end
# Used everywhere
@order.total = OrderTotalCalculator.new(@order).calculate
React Example
Before: Copy-pasted validation
function RegistrationForm() {
const validateEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
// ...
}
function ProfileForm() {
const validateEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
// ...
}
function InviteForm() {
const validateEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
// ...
}
After: Shared utility
// utils/validation.ts
export function isValidEmail(email: string): boolean {
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return EMAIL_REGEX.test(email);
}
// Or as a custom hook for form integration
export function useEmailValidation() {
const validate = useCallback((email: string) => {
if (!email) return 'Email is required';
if (!isValidEmail(email)) return 'Invalid email format';
return undefined;
}, []);
return { validate };
}
Conclusion
Martin's principles for writing clean functions are timeless. Whether you're building Rails APIs or React interfaces, the rules remain the same:
- Keep functions small and focused
- Make them do one thing well
- Maintain consistent abstraction levels
- Choose names that reveal intent
- Minimize arguments
- Avoid side effects
- Separate commands from queries
- Use exceptions over error codes
- Eliminate duplication
The goal isn't to follow these rules dogmatically, but to write code that communicates clearly. When your functions are small, well-named, and focused, your codebase becomes a joy to work in rather than a chore to maintain.
This is part of a series on Clean Code principles with Rails and React examples. See also: Chapter 1: Clean Code and Chapter 2: Meaningful Names.
No comments:
Post a Comment