Think in use cases, not endpoints
Stop designing your application around HTTP verbs and database tables. Start designing around what your users are actually trying to accomplish.
Stop designing your application around HTTP verbs and database tables. Start designing around what your users are actually trying to accomplish.
I've been building web applications for over a decade, and I've noticed that what separates good developers from great ones is how they think about their applications. Most developers think in terms of resources, routes, and CRUD operations. They see a "posts" table and immediately think, "I need a PostController with index, show, store, update, and destroy methods."
But your users don't think in HTTP verbs. They don't care about your database schema. They have goals, intentions, and tasks they want to accomplish.
This article is about making a fundamental shift in how you approach application design from thinking in technical implementation details to thinking in user use cases.
The problem: Building around technical constraints
A few years ago, I inherited a Laravel-based content management system. The previous team had built it "by the book" (perfectly RESTful, well-organized controllers, and clean database design). It looked textbook perfect.
But when I started digging into how it actually worked, I realized they had built the entire system around their database tables, not around what users were trying to do. Here's what the architecture looked like:
// "Perfect" RESTful design
Route::resource('posts', PostController::class);
Route::resource('categories', CategoryController::class);
Route::resource('tags', TagController::class);
Route::resource('comments', CommentController::class);
Route::resource('users', UserController::class);
class PostController extends Controller
{
public function store(Request $request) { /* create a post */ }
public function update(Request $request, Post $post) { /* update a post */ }
public function destroy(Post $post) { /* delete a post */ }
// ... standard CRUD operations
}Looked clean, right? But here's what I discovered when I talked to the actual users of this system:
Nobody was just "creating posts." They were:
- Publishing articles for immediate release
- Scheduling content for future publication
- Saving drafts to work on later
- Submitting articles for review by editors
- Converting drafts to published articles after approval
Nobody was just "updating posts." They were:
- Editing drafts without affecting live content
- Republishing articles with corrections
- Unpublishing content due to legal issues
- Archiving old content for SEO purposes
The technical implementation was perfect, but it completely missed how humans actually interact with a content management system.
The mental shift: From resources to intentions
The breakthrough moment came when I stopped thinking about what the system could do technically and started thinking about what users were trying to accomplish. Instead of asking "What CRUD operations do I need?" I started asking, "What are users trying to achieve?"
This shift in perspective changed everything. Instead of designing around database tables and HTTP verbs, I started designing around user intentions. Each intention became what I call a "use case" — a discrete goal that a user wants to accomplish.
Here's how that same content management system looked after I redesigned it around use cases:
Publishing use cases:
- Publish Article Immediately
- Schedule Article for Later
- Submit Article for Review
- Approve Article for Publication
Content Management use cases:
- Save Draft
- Edit Live Content
- Archive Old Content
- Restore Archived Content
Workflow use cases:
- Review Submitted Article
- Request Changes to Article
- Convert Draft to Live Article
Notice how each of these represents something a human being actually wants to do? They're not technical operations but business goals.
Implementing use-case driven design
Once I started thinking this way, the implementation became much clearer. Instead of generic CRUD controllers, I created action classes that represented each use case:
// Instead of generic PostController::store()
class PublishArticleImmediately
{
public function __invoke(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'category_id' => 'required|exists:categories,id',
]);
$article = $this->handle(
title: $request->input('title'),
content: $request->input('content'),
categoryId: $request->input('category_id'),
author: $request->user()
);
return redirect()->route('articles.show', $article)
->with('success', 'Article published successfully!');
}
public function handle(string $title, string $content, int $categoryId, User $author): Article
{
return DB::transaction(function () use ($title, $content, $categoryId, $author) {
$article = Article::create([
'title' => $title,
'content' => $content,
'category_id' => $categoryId,
'author_id' => $author->id,
'status' => 'published',
'published_at' => now(),
'slug' => Str::slug($title),
]);
$this->generateSEOMetadata($article);
$this->notifySubscribers($article);
$this->addToSearchIndex($article);
$this->trackPublishingEvent($article);
return $article;
});
}
private function generateSEOMetadata(Article $article): void
{
// SEO-specific logic
}
private function notifySubscribers(Article $article): void
{
// Notification logic
}
// ... other private methods
}Now compare this to scheduling an article:
class ScheduleArticleForLater
{
public function __invoke(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'category_id' => 'required|exists:categories,id',
'publish_at' => 'required|date|after:now',
]);
$article = $this->handle(
title: $request->input('title'),
content: $request->input('content'),
categoryId: $request->input('category_id'),
publishAt: Carbon::parse($request->input('publish_at')),
author: $request->user()
);
return redirect()->route('articles.show', $article)
->with('success', 'Article scheduled for publication!');
}
public function handle(string $title, string $content, int $categoryId, Carbon $publishAt, User $author): Article
{
return DB::transaction(function () use ($title, $content, $categoryId, $publishAt, $author) {
$article = Article::create([
'title' => $title,
'content' => $content,
'category_id' => $categoryId,
'author_id' => $author->id,
'status' => 'scheduled',
'publish_at' => $publishAt,
'slug' => Str::slug($title),
]);
$this->schedulePublishingJob($article);
$this->notifyEditorsOfScheduledContent($article);
$this->trackSchedulingEvent($article);
return $article;
});
}
// ... different private methods for scheduling logic
}See the difference? These aren't just different flavors of "create a post." They're completely different use cases with distinct validation rules, business logic, and side effects.
The layers of user interaction
When you start thinking in use cases, you begin to see your application as a series of layers that users interact with:
Layer 1: The user interface
This is where users express their intentions. Whether it's a web form, an API endpoint, or a CLI command, this layer captures what the user wants to do.
Layer 2: The use case layer
This is where user intentions get translated into business operations. Each use case represents one discrete thing a user wants to accomplish.
Layer 3: The domain layer
This is where your core business logic lives — the rules, calculations, and operations that define how your application works.
Layer 4: The infrastructure layer
This is where you interact with databases, external services, file systems, and other technical concerns.
Here's how this looks in practice:
// Layer 1: User Interface (Web Controller)
Route::post('/articles/publish', PublishArticleImmediately::class);
// Layer 2: Use Case (The Action Class)
class PublishArticleImmediately
{
public function handle(string $title, string $content, int $categoryId, User $author): Article
{
// Orchestrates the business operation
$article = $this->articleService->createArticle($title, $content, $categoryId, $author);
$this->seoService->generateMetadata($article);
$this->notificationService->notifySubscribers($article);
return $article;
}
}
// Layer 3: Domain Services
class ArticleService
{
public function createArticle(string $title, string $content, int $categoryId, User $author): Article
{
// Core business logic
if (!$author->canPublishArticles()) {
throw new UnauthorizedException();
}
return Article::create([
'title' => $title,
'content' => $content,
'category_id' => $categoryId,
'author_id' => $author->id,
'status' => 'published',
'published_at' => now(),
'slug' => $this->generateUniqueSlug($title),
]);
}
}
// Layer 4: Infrastructure (Repository/Database)
class ArticleRepository
{
public function create(array $data): Article
{
return Article::create($data);
}
public function findBySlug(string $slug): ?Article
{
return Article::where('slug', $slug)->first();
}
}Discovering use cases in your application
The hardest part about use-case driven design isn't the implementation — it's discovering what your use cases actually are. Here's the process I follow:
1. Start with user stories
Instead of thinking about what your system needs to do technically, think about what your users need to accomplish. I like to use this format:
"As a [type of user], I want to [accomplish a goal] so that [I get some benefit]."
For the CMS example:
- "As a content creator, I want to publish an article immediately so that my audience can read it right away."
- "As an editor, I want to schedule articles for publication so that content goes live at optimal times."
- "As a content creator, I want to save drafts so that I can work on articles over multiple sessions."
2. Map user journeys
Think about the complete path a user takes to accomplish their goal. Don't just focus on the happy path — consider error cases, alternative flows, and edge cases.
For publishing an article:
- User writes content
- User selects category and tags
- User chooses to publish immediately or schedule for later
- System validates content meets publication standards
- System publishes/schedules the article
- System notifies relevant stakeholders
- User gets confirmation of success
3. Identify the boundaries
Each use case should have clear boundaries. Ask yourself:
- When does this use case start?
- When does it end?
- What triggers it?
- What's the expected outcome?
- What can go wrong?
4. Look for variations
Often, what looks like one use case is actually several related use cases. "Update a post" might actually be:
- Edit a draft
- Publish changes to a live article
- Schedule updates for later
- Revert to a previous version
Benefits of use-case thinking
After rebuilding that CMS around use cases, the benefits were immediately obvious:
- Clarity for everyone. When a stakeholder asked "Can users publish articles directly?" I could point to the
PublishArticleImmediatelyuse case. When a developer needed to understand the scheduling feature, they could look atScheduleArticleForLater. No hunting through generic controllers trying to figure out what different code paths did. - Testing became intuitive. Each use case could be tested independently. Want to test article publishing? Test the
PublishArticleImmediatelyuse case. Want to test scheduling? TestScheduleArticleForLater. No more massive controller tests trying to cover every possible scenario. - Features became reusable. The article publishing logic worked equally well from the web interface, the mobile app API, and the CLI tool for bulk importing. Because the use case was separate from the delivery mechanism, it could be used anywhere.
- Changes became surgical. When the business wanted to change how article scheduling worked, I only had to modify the
ScheduleArticleForLateruse case. When they wanted to add approval workflows, I created new use cases likeSubmitArticleForReviewandApproveArticleForPublicationwithout touching existing code.
Real-world example: E-commerce order processing
Let me show you another example from an e-commerce project I worked on. The original system had a typical OrderController with standard CRUD operations:
class OrderController extends Controller
{
public function store(Request $request) { /* create order */ }
public function update(Request $request, Order $order) { /* update order */ }
public function destroy(Order $order) { /* cancel order */ }
}But when I mapped out what users actually wanted to do with orders, I discovered these use cases:
Customer use cases:
- Place Order with Payment
- Place Order with Store Credit
- Schedule Recurring Order
- Modify Order Before Shipping
- Cancel Order Before Shipping
- Return Order After Delivery
Admin use cases:
- Process Order Payment
- Mark Order as Shipped
- Refund Order
- Exchange Items in Order
- Split Order into Multiple Shipments
Each of these became its own action class:
class PlaceOrderWithPayment
{
public function handle(
User $customer,
Collection $items,
Address $shippingAddress,
PaymentMethod $paymentMethod
): Order {
return DB::transaction(function () use ($customer, $items, $shippingAddress, $paymentMethod) {
$this->validateCustomerCanOrder($customer);
$this->validateItemsInStock($items);
$order = $this->createOrder($customer, $items, $shippingAddress);
$this->processPayment($order, $paymentMethod);
$this->reserveInventory($order);
$this->sendOrderConfirmation($order);
return $order;
});
}
}
class ScheduleRecurringOrder
{
public function handle(
User $customer,
Collection $items,
Address $shippingAddress,
PaymentMethod $paymentMethod,
string $frequency
): RecurringOrder {
return DB::transaction(function () use ($customer, $items, $shippingAddress, $paymentMethod, $frequency) {
$this->validateCustomerCanCreateRecurringOrders($customer);
$this->validateRecurringItemsAvailable($items);
$recurringOrder = $this->createRecurringOrder($customer, $items, $shippingAddress, $frequency);
$this->validatePaymentMethod($paymentMethod);
$this->scheduleFirstOrder($recurringOrder);
return $recurringOrder;
});
}
}Notice how different these are? They're not just variations of "create an order" — they're completely different business operations with different rules, different validation, and different outcomes.
The API design revolution
One unexpected benefit of use-case thinking was how it changed my approach to API design. Instead of building RESTful APIs around resources, I started building them around user intentions — what I like to call "behaviour driven APIs".
Traditional resource-based API:
POST /api/orders
PUT /api/orders/{id}
DELETE /api/orders/{id}Use-case-based API:
POST /api/orders/place-with-payment
POST /api/orders/place-with-store-credit
POST /api/orders/schedule-recurring
POST /api/orders/{id}/modify-before-shipping
POST /api/orders/{id}/cancel-before-shipping
POST /api/orders/{id}/return-after-deliveryThe use-case-based API is so much clearer! Each endpoint represents exactly one thing a user wants to do, their intentions, and the behaviour they expect to happen. There's no ambiguity about what data to send or what the endpoint will do with it.
Common pitfalls and how to avoid them
After using this approach on several projects, I've seen developers make some common mistakes:
Pitfall 1: Making use cases too generic
Don't create use cases like ManageOrder or HandleUserAction. These are just service classes in disguise. Each use case should represent one specific user intention.
Pitfall 2: Forgetting about boundaries
Use cases should be independent. If you find yourself calling one use case from inside another, you probably need to rethink your boundaries.
Pitfall 3: Mixing layers
Keep your use cases focused on orchestration, not implementation. They should coordinate domain services and infrastructure, not contain detailed business logic themselves.
Pitfall 4: Over-engineering simple operations
Not everything needs to be a use case. Simple CRUD operations without complex business rules can remain in traditional controllers. Use cases represent meaningful user intentions and are for operations.
The testing revolution
Perhaps the biggest benefit of use-case thinking is its transformative effect on testing. Each use case can be tested in complete isolation:
class PlaceOrderWithPaymentTest extends TestCase
{
public function test_can_place_order_with_valid_payment()
{
$customer = User::factory()->create();
$items = collect([
new OrderItem(Product::factory()->create(), 2),
new OrderItem(Product::factory()->create(), 1),
]);
$address = Address::factory()->create();
$paymentMethod = PaymentMethod::factory()->create();
$useCase = new PlaceOrderWithPayment();
$order = $useCase->handle($customer, $items, $address, $paymentMethod);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals('completed', $order->status);
$this->assertTrue($order->payment->isProcessed());
}
public function test_throws_exception_when_customer_cannot_order()
{
$bannedCustomer = User::factory()->banned()->create();
// ... test setup
$this->expectException(CustomerCannotOrderException::class);
$useCase = new PlaceOrderWithPayment();
$useCase->handle($bannedCustomer, $items, $address, $paymentMethod);
}
}These tests are fast, focused, and don't require any HTTP infrastructure. They test exactly what matters: the business logic of the use case.
Making the transition
If you're working on an existing application, don't try to refactor everything at once. Start by identifying your most complex controller methods and ask yourself: "What is the user actually trying to accomplish here?"
Begin with new features. The next time you need to add functionality, design it as a use case from the start. Once you get comfortable with the pattern, you can gradually extract existing functionality into use cases.
Remember, this isn't about perfect architecture — it's about aligning your code with how humans actually think about and use your application.
The mental framework
The key insight is to stop thinking of your application as a collection of resources that require CRUD operations. Start thinking about it as a collection of user intentions that need to be fulfilled.
When you approach a new feature, don't ask:
- "What database tables do I need?"
- "What endpoints should I create?"
- "What controllers do I need?"
Instead, ask:
- "What is the user trying to accomplish?"
- "What does success look like for them?"
- "What can go wrong along the way?"
- "What information do they need to provide?"
- "What should happen after they succeed?"
This shift in thinking will transform not just your code, but how you understand and build software. Your applications will become more intuitive, more maintainable, and more aligned with how humans actually work.
The next time you sit down to design a feature, try thinking in use cases first. Your users and your future self will thank you for it.