Every developer hits the same small decision dozens of times a day: should this logic stay where it is, become its own function, move into a class, turn into a service, or deserve a whole module? Get it wrong in one direction and you end up with a 600-line controller. Get it wrong in the other and a simple feature is spread across nine files with names like OrderProcessorFactoryManager.
There is no magic line count that settles it. What works in practice is a small set of questions you can ask each time. This guide walks through those questions level by level, with short examples from the kind of code most of us write every week: Laravel backends, React frontends, PostgreSQL-backed ERP screens.
Start With the Real Question: What Is Changing Together?
Before talking about functions or modules, it helps to name the underlying rule. Code should be grouped by what changes together and separated by what changes for different reasons. A tax calculation and an email template change for different reasons, so they should not live in the same block. A tax rate and the rounding rule for that tax change together, so splitting them across three layers just makes every change harder.
Every extraction decision below is a version of that idea at a different size.
When to Create a New Function
Functions are the cheapest unit of extraction, so the bar is low. Create one when at least one of these is true:
- You can give the block a clear name. If a few lines need a comment to explain what they do, the comment is usually the function name waiting to happen.
- The same logic appears twice and must stay identical. Two copies of a discount rule will drift apart. Two similar-looking loops that serve different purposes may not need merging at all.
- A block sits at a different level of detail. A function that reads "load order, check stock, charge card" should not suddenly contain twenty lines of string formatting.
- You want to test a rule on its own. Pure functions with inputs and outputs are the easiest code to test.
// Before: intent hidden inside the handler
if (user.role === 'admin' || (user.role === 'manager' && user.branchId === invoice.branchId)) {
approve(invoice);
}
// After: the rule has a name and can be tested
function canApproveInvoice(user, invoice) {
if (user.role === 'admin') return true;
return user.role === 'manager' && user.branchId === invoice.branchId;
}
if (canApproveInvoice(user, invoice)) approve(invoice);When not to bother: a one-line expression that is already obvious, or a "helper" that only forwards its arguments to another function. Extraction should make the reader's job easier, not add a jump.
When to Create a New Class
A class earns its place when data and the behaviour around it belong together and you keep passing the same group of values into the same group of functions. Common signals:
- Three or more functions share the same four parameters, such as
amount,currency,taxRateandroundingMode. - A value has rules that should always hold, for example "a date range must end after it starts". A small value object can enforce that once instead of everywhere.
- Something has a lifecycle or state, like a draft, submitted and approved purchase order.
final class DateRange
{
public function __construct(
public readonly CarbonImmutable $start,
public readonly CarbonImmutable $end,
) {
if ($end->lte($start)) {
throw new InvalidArgumentException('End must be after start.');
}
}
public function overlaps(DateRange $other): bool
{
return $this->start->lt($other->end) && $other->start->lt($this->end);
}
}Now every booking, lease or payroll period that uses DateRange gets validation and overlap logic for free. The warning sign in the other direction is a class with no state and one public method that is never swapped or mocked. That is often just a function wearing a costume.

When to Create a New Service
In Laravel and similar frameworks, a "service" usually means a class that coordinates a use case: placing an order, approving a leave request, generating a monthly statement. Create one when:
- The same workflow is triggered from more than one place. If a controller, a queued job and an Artisan command all need to "close the month", that logic cannot live in the controller.
- The workflow touches several models or external systems. Writing to the database, calling a payment gateway and dispatching notifications in one transaction is coordination, and coordination deserves a home.
- The controller is doing business decisions. Controllers should translate HTTP into a call and a response, not decide refund eligibility.
class ApproveLeaveRequest
{
public function __construct(private LeaveBalanceRepository $balances) {}
public function handle(LeaveRequest $request, User $approver): void
{
DB::transaction(function () use ($request, $approver) {
$this->balances->deduct($request->employee_id, $request->days);
$request->markApprovedBy($approver);
});
LeaveApproved::dispatch($request);
}
}If you are unsure where the boundary between controller, service and repository should sit, the article Controller vs Service vs Repository: Where Should Your Logic Go? goes deeper on that split.
When not to: wrapping a single Eloquent call in UserService::find() adds a layer without adding a decision. A service should exist because a workflow exists, not because the folder structure expects one.
When to Create a New Module
A module is a group of related classes, routes, migrations and UI that represent one business capability, such as Inventory, Payroll or Leasing. It is the most expensive extraction because it introduces a boundary other code must respect. Reach for it when:
- A feature area has its own vocabulary. If the inventory team says "stock movement" and the finance team says "journal entry", those are probably different modules.
- Changes in one area keep breaking another. A clear boundary with a small public interface stops accidental coupling.
- Different people or teams own different parts. Modules make ownership visible in the codebase.
- You may want to deploy, scale or replace it separately one day. A well-bounded module is the safest first step, long before microservices.
In a React or Next.js app, the same idea shows up as feature folders: features/invoices owns its components, hooks and API calls, and other features import only what it exports from its index file. For a full layout in Laravel, see How to Structure a Laravel Project for Large Applications.
A Quick Checklist You Can Use Today
- Can I name this block clearly? If yes, a function is probably worth it.
- Do the same values keep travelling together with rules attached? Consider a class or value object.
- Is this a business workflow used from several entry points? Create a service or action class.
- Is this a whole capability with its own language and owners? Draw a module boundary.
- Would the extraction add a file but no decision? Leave it inline for now.
Common Mistakes to Avoid
Extracting too early
Creating interfaces, factories and services for a feature that has one caller and no second use case yet makes code harder to follow. Wait until a real second need appears, then extract with the knowledge of both cases.
Extracting by technical layer only
Folders named Helpers, Utils and Managers tend to become junk drawers. Group by what the code is about, such as Billing or Bookings, not only by what kind of file it is.
Never extracting at all
The opposite failure is the method that grew one condition at a time until nobody dares touch it. Code that works can still be painful to change, as covered in Why Your Code Works but Is Still Bad Code.
Conclusion
Functions, classes, services and modules are not a hierarchy you must fill in. They are tools of increasing size and cost. Start small, name things clearly, and move up a level only when you see the signal for it: a nameable rule, data with behaviour, a shared workflow, or a separate business capability. That habit keeps a codebase easy to read in its first month and still easy to change in its fifth year.
