From 398f4c1a8a6676cd7353826db19334eafc929cff Mon Sep 17 00:00:00 2001 From: Alex Garrett-Smith Date: Thu, 17 Sep 2026 13:42:30 +0100 Subject: [PATCH] Merge duplicate ideas --- app/Models/Idea.php | 9 + .../Feedback/Actions}/ToggleVote.php | 2 +- .../Http/Controllers}/CommentController.php | 2 +- .../Http/Controllers/DashboardController.php | 3 +- .../Http/Controllers/IdeaController.php | 7 +- .../Http/Controllers/ReactionController.php | 5 +- .../Controllers/UnsubscribeController.php | 3 +- .../Http/Controllers/VoteController.php | 5 +- .../Http/Requests/StoreIdeaRequest.php | 2 +- .../Http/Requests/StoreReactionRequest.php | 2 +- .../Http/Requests/UpdateIdeaRequest.php | 2 +- .../Providers/FeedbackServiceProvider.php | 13 + app/Modules/Feedback/Routes/web.php | 32 ++ .../Http/Controllers/CommentController.php | 3 +- .../Controllers}/IdeaDashboardController.php | 2 +- .../Controllers}/IdeaDetailController.php | 5 +- .../Http/Controllers/IdeaMergeController.php | 32 ++ .../Controllers}/IdeaStatusController.php | 4 +- .../Http/Controllers}/NoteController.php | 4 +- .../Http/Middleware/EnsureTeamMember.php | 2 +- .../Requests/StoreInternalNoteRequest.php | 2 +- .../Http/Requests/UpdateIdeaStatusRequest.php | 2 +- .../Providers/InternalServiceProvider.php | 13 + app/Modules/Internal/Routes/web.php | 20 + bootstrap/app.php | 2 +- bootstrap/providers.php | 2 + ...0000_add_merged_into_id_to_ideas_table.php | 22 + docs/feedback.md | 2 + docs/internal-dashboard.md | 22 + ...ng-the-monolith-a-real-world-experience.md | 440 ++++++++++++++++++ docs/reference/module-boundaries.md | 9 + resources/js/Components/MergeIdeaForm.vue | 51 ++ resources/js/Components/ReactionBar.vue | 2 +- resources/js/Components/StatusUpdateForm.vue | 2 +- resources/js/Components/VoteButton.vue | 2 +- resources/js/Pages/Dashboard.vue | 2 +- resources/js/Pages/Ideas/Show.vue | 2 +- resources/js/Pages/Internal/Ideas/Index.vue | 2 +- resources/js/Pages/Internal/Ideas/Show.vue | 13 +- .../js/actions/App/Http/Controllers/index.ts | 14 - .../Http/Controllers/CommentController.ts | 14 +- .../Http/Controllers/DashboardController.ts | 18 +- .../Http/Controllers/IdeaController.ts | 86 ++-- .../Http/Controllers/ReactionController.ts | 14 +- .../Http/Controllers/UnsubscribeController.ts | 18 +- .../Http/Controllers/VoteController.ts | 14 +- .../Feedback/Http/Controllers/index.ts | 17 + .../App/Modules/Feedback/Http/index.ts | 7 + .../js/actions/App/Modules/Feedback/index.ts | 7 + .../Http/Controllers}/CommentController.ts | 14 +- .../Controllers}/IdeaDashboardController.ts | 18 +- .../Http/Controllers}/IdeaDetailController.ts | 18 +- .../Http/Controllers/IdeaMergeController.ts | 62 +++ .../Http/Controllers}/IdeaStatusController.ts | 14 +- .../Http/Controllers}/NoteController.ts | 14 +- .../Internal/Http/Controllers}/index.ts | 6 +- .../App/Modules/Internal/Http/index.ts | 7 + .../js/actions/App/Modules/Internal/index.ts | 7 + resources/js/actions/App/Modules/index.ts | 9 + resources/js/actions/App/index.ts | 2 + .../js/routes/feedback/comments/index.ts | 12 +- resources/js/routes/feedback/index.ts | 124 ++--- resources/js/routes/index.ts | 88 ++-- .../routes/internal/ideas/comments/index.ts | 12 +- resources/js/routes/internal/ideas/index.ts | 34 +- .../js/routes/internal/ideas/merge/index.ts | 64 +++ .../js/routes/internal/ideas/notes/index.ts | 12 +- .../js/routes/internal/ideas/status/index.ts | 12 +- routes/web.php | 33 -- tests/Feature/Internal/MergeIdeaTest.php | 25 + tests/Feature/TeamMemberMiddlewareTest.php | 2 +- vite.config.js | 4 +- 72 files changed, 1202 insertions(+), 351 deletions(-) rename app/{Actions/Votes => Modules/Feedback/Actions}/ToggleVote.php (94%) rename app/{Http/Controllers/Internal => Modules/Feedback/Http/Controllers}/CommentController.php (91%) rename app/{ => Modules/Feedback}/Http/Controllers/DashboardController.php (80%) rename app/{ => Modules/Feedback}/Http/Controllers/IdeaController.php (89%) rename app/{ => Modules/Feedback}/Http/Controllers/ReactionController.php (83%) rename app/{ => Modules/Feedback}/Http/Controllers/UnsubscribeController.php (82%) rename app/{ => Modules/Feedback}/Http/Controllers/VoteController.php (71%) rename app/{ => Modules/Feedback}/Http/Requests/StoreIdeaRequest.php (88%) rename app/{ => Modules/Feedback}/Http/Requests/StoreReactionRequest.php (88%) rename app/{ => Modules/Feedback}/Http/Requests/UpdateIdeaRequest.php (91%) create mode 100644 app/Modules/Feedback/Providers/FeedbackServiceProvider.php create mode 100644 app/Modules/Feedback/Routes/web.php rename app/{ => Modules/Internal}/Http/Controllers/CommentController.php (84%) rename app/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaDashboardController.php (91%) rename app/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaDetailController.php (80%) create mode 100644 app/Modules/Internal/Http/Controllers/IdeaMergeController.php rename app/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaStatusController.php (93%) rename app/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/NoteController.php (83%) rename app/{ => Modules/Internal}/Http/Middleware/EnsureTeamMember.php (89%) rename app/{ => Modules/Internal}/Http/Requests/StoreInternalNoteRequest.php (86%) rename app/{ => Modules/Internal}/Http/Requests/UpdateIdeaStatusRequest.php (90%) create mode 100644 app/Modules/Internal/Providers/InternalServiceProvider.php create mode 100644 app/Modules/Internal/Routes/web.php create mode 100644 database/migrations/2026_09_17_000000_add_merged_into_id_to_ideas_table.php create mode 100644 docs/reference/modularizing-the-monolith-a-real-world-experience.md create mode 100644 docs/reference/module-boundaries.md create mode 100644 resources/js/Components/MergeIdeaForm.vue rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/CommentController.ts (77%) rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/DashboardController.ts (59%) rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/IdeaController.ts (73%) rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/ReactionController.ts (77%) rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/UnsubscribeController.ts (76%) rename resources/js/actions/App/{ => Modules/Feedback}/Http/Controllers/VoteController.ts (77%) create mode 100644 resources/js/actions/App/Modules/Feedback/Http/Controllers/index.ts create mode 100644 resources/js/actions/App/Modules/Feedback/Http/index.ts create mode 100644 resources/js/actions/App/Modules/Feedback/index.ts rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/CommentController.ts (77%) rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaDashboardController.ts (59%) rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaDetailController.ts (74%) create mode 100644 resources/js/actions/App/Modules/Internal/Http/Controllers/IdeaMergeController.ts rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/IdeaStatusController.ts (76%) rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/NoteController.ts (77%) rename resources/js/actions/App/{Http/Controllers/Internal => Modules/Internal/Http/Controllers}/index.ts (78%) create mode 100644 resources/js/actions/App/Modules/Internal/Http/index.ts create mode 100644 resources/js/actions/App/Modules/Internal/index.ts create mode 100644 resources/js/actions/App/Modules/index.ts create mode 100644 resources/js/routes/internal/ideas/merge/index.ts create mode 100644 tests/Feature/Internal/MergeIdeaTest.php diff --git a/app/Models/Idea.php b/app/Models/Idea.php index 8a80b1b..3138028 100644 --- a/app/Models/Idea.php +++ b/app/Models/Idea.php @@ -21,6 +21,7 @@ class Idea extends Model protected $fillable = [ 'title', 'description', + 'merged_into_id', ]; /** @@ -41,6 +42,14 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + /** + * @return BelongsTo + */ + public function mergedInto(): BelongsTo + { + return $this->belongsTo(Idea::class, 'merged_into_id'); + } + /** * @return BelongsToMany */ diff --git a/app/Actions/Votes/ToggleVote.php b/app/Modules/Feedback/Actions/ToggleVote.php similarity index 94% rename from app/Actions/Votes/ToggleVote.php rename to app/Modules/Feedback/Actions/ToggleVote.php index 63d5357..7316089 100644 --- a/app/Actions/Votes/ToggleVote.php +++ b/app/Modules/Feedback/Actions/ToggleVote.php @@ -1,6 +1,6 @@ loadRoutesFrom(__DIR__.'/../Routes/web.php'); + } +} diff --git a/app/Modules/Feedback/Routes/web.php b/app/Modules/Feedback/Routes/web.php new file mode 100644 index 0000000..44f5243 --- /dev/null +++ b/app/Modules/Feedback/Routes/web.php @@ -0,0 +1,32 @@ +group(function () { + Route::get('/dashboard', DashboardController::class)->name('dashboard'); + + Route::middleware('auth')->group(function () { + Route::get('/feedback/create', [IdeaController::class, 'create'])->name('feedback.create'); + Route::post('/feedback', [IdeaController::class, 'store'])->name('feedback.store'); + + Route::get('/feedback/{idea}/edit', [IdeaController::class, 'edit'])->name('feedback.edit'); + Route::put('/feedback/{idea}', [IdeaController::class, 'update'])->name('feedback.update'); + Route::delete('/feedback/{idea}', [IdeaController::class, 'destroy'])->name('feedback.destroy'); + + Route::post('/feedback/{idea}/vote', VoteController::class)->name('feedback.vote'); + Route::post('/feedback/{idea}/reactions', ReactionController::class)->name('feedback.react'); + Route::post('/feedback/{idea}/comments', [CommentController::class, 'store'])->name('feedback.comments.store'); + }); + + Route::get('/feedback/{idea}', [IdeaController::class, 'show'])->name('feedback.show'); + + Route::get('/feedback/{idea}/unsubscribe/{user}', UnsubscribeController::class) + ->middleware('signed') + ->name('feedback.unsubscribe'); +}); diff --git a/app/Http/Controllers/CommentController.php b/app/Modules/Internal/Http/Controllers/CommentController.php similarity index 84% rename from app/Http/Controllers/CommentController.php rename to app/Modules/Internal/Http/Controllers/CommentController.php index 1e21128..048a03d 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Modules/Internal/Http/Controllers/CommentController.php @@ -1,8 +1,9 @@ new IdeaResource($idea), + 'mergeTargets' => Idea::where('id', '!=', $idea->id) + ->orderBy('title') + ->get(['id', 'title']), 'comments' => CommentResource::collection( $idea->publicComments()->with('user')->oldest()->get() ), diff --git a/app/Modules/Internal/Http/Controllers/IdeaMergeController.php b/app/Modules/Internal/Http/Controllers/IdeaMergeController.php new file mode 100644 index 0000000..8759949 --- /dev/null +++ b/app/Modules/Internal/Http/Controllers/IdeaMergeController.php @@ -0,0 +1,32 @@ +input('target_id')); + + $idea->comments()->update(['idea_id' => $target->id]); + $idea->reactions()->update(['idea_id' => $target->id]); + + foreach ($idea->voters as $voter) { + $target->voters()->attach($voter->id); + } + + $idea->merged_into_id = $target->id; + $idea->status = IdeaStatus::Declined; + $idea->save(); + + return redirect() + ->route('internal.ideas.show', $target) + ->with('status', 'Idea merged into '.$target->title.'.'); + } +} diff --git a/app/Http/Controllers/Internal/IdeaStatusController.php b/app/Modules/Internal/Http/Controllers/IdeaStatusController.php similarity index 93% rename from app/Http/Controllers/Internal/IdeaStatusController.php rename to app/Modules/Internal/Http/Controllers/IdeaStatusController.php index c37971f..99a8562 100644 --- a/app/Http/Controllers/Internal/IdeaStatusController.php +++ b/app/Modules/Internal/Http/Controllers/IdeaStatusController.php @@ -1,12 +1,12 @@ loadRoutesFrom(__DIR__.'/../Routes/web.php'); + } +} diff --git a/app/Modules/Internal/Routes/web.php b/app/Modules/Internal/Routes/web.php new file mode 100644 index 0000000..130ee38 --- /dev/null +++ b/app/Modules/Internal/Routes/web.php @@ -0,0 +1,20 @@ +group(function () { + Route::prefix('internal')->middleware(['auth', 'team'])->name('internal.')->group(function () { + Route::get('/', IdeaDashboardController::class)->name('ideas.index'); + Route::get('/ideas/{idea}', [IdeaDetailController::class, 'show'])->name('ideas.show'); + Route::post('/ideas/{idea}/comments', [CommentController::class, 'store'])->name('ideas.comments.store'); + Route::post('/ideas/{idea}/notes', [NoteController::class, 'store'])->name('ideas.notes.store'); + Route::patch('/ideas/{idea}/status', [IdeaStatusController::class, 'update'])->name('ideas.status.update'); + Route::post('/ideas/{idea}/merge', [IdeaMergeController::class, 'store'])->name('ideas.merge.store'); + }); +}); diff --git a/bootstrap/app.php b/bootstrap/app.php index d0a9657..11f9230 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,7 +17,7 @@ ]); $middleware->alias([ - 'team' => \App\Http\Middleware\EnsureTeamMember::class, + 'team' => \App\Modules\Internal\Http\Middleware\EnsureTeamMember::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c57..fe2eeb1 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,8 @@ foreignId('merged_into_id')->nullable()->constrained('ideas')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('ideas', function (Blueprint $table) { + $table->dropConstrainedForeignId('merged_into_id'); + }); + } +}; diff --git a/docs/feedback.md b/docs/feedback.md index e3df183..9c11262 100644 --- a/docs/feedback.md +++ b/docs/feedback.md @@ -12,6 +12,8 @@ Signed-in users create feedback from the `Ideas/Create` page. New ideas start with the default status defined by the database (see [Internal team dashboard](internal-dashboard.md) for the status pipeline). +If an idea turns out to be a duplicate, a team member can merge it into the idea being kept, which moves its comments, reactions, and votes across and closes the duplicate as Declined. See [Merging duplicate ideas](internal-dashboard.md#merging-duplicate-ideas). + ## Viewing feedback `GET /feedback/{idea}` (`feedback.show`) renders the `Ideas/Show` page. This route is public — anyone, signed in or not, can view an idea. The page includes: diff --git a/docs/internal-dashboard.md b/docs/internal-dashboard.md index 5d894e6..54249c6 100644 --- a/docs/internal-dashboard.md +++ b/docs/internal-dashboard.md @@ -46,6 +46,28 @@ The `idea_status_updates` table carries a unique index, `idea_status_transition_ The same transition made by a **different** team member is unaffected, as is the same team member making any other transition on that idea. +## Merging duplicate ideas + +When the same suggestion has been submitted more than once, a team member can merge the duplicate into the idea they want to keep. The merge form sits on the idea detail page, below the status controls: a dropdown listing every **other** idea (passed to the page as `mergeTargets` — id and title only, ordered by title) and a **Merge** button. + +`POST /internal/ideas/{idea}/merge` (`internal.ideas.merge.store`) takes `target_id`, the idea being kept. The idea in the URL is the duplicate being merged away, and for it the controller: + +- Reassigns **all** of its comments to the target, internal notes included. +- Reassigns its reactions to the target. +- Attaches each of its voters to the target. +- Stores the target in `merged_into_id` (exposed on the model as the `mergedInto` relationship). +- Sets its status to **Declined**. + +The team member is redirected to the target idea with "Idea merged into ...". + +### Things to know + +- The status change is written straight to the idea. Unlike a status change made through the status pipeline, no `IdeaStatusUpdate` record is created and **no subscribers are emailed**, so the merge leaves no trace in the idea's history and people following the duplicate are not told about it. +- Votes and reactions are moved without checking for overlap. `idea_vote` is unique across `idea_id` and `user_id`, and `reactions` is unique across `idea_id`, `user_id`, and `emoji`. If the same person voted on both ideas, or left the same emoji on both, the merge fails with a database error (reported to Sentry — see [Error monitoring](error-monitoring.md)) rather than an inline message. +- The merge is not wrapped in a transaction, so a failure part-way through leaves the earlier steps applied — comments and reactions may already have moved to the target while the votes did not. +- `target_id` is not validated. The dropdown's placeholder option submits an empty value, and an empty or unknown id fails with an error rather than a validation message. Nothing stops an idea being merged into itself or into an idea that has already been merged away. +- Merging does not hide or redirect the duplicate. It still appears (as Declined) on the public dashboard, in the internal idea list, and in the merge dropdown of other ideas — but its comments, reactions, and votes now live on the target. `merged_into_id` is not exposed by `IdeaResource`, so no page links a merged idea to the idea it was merged into. + ## Comments and internal notes Team members can post to either thread on an idea: diff --git a/docs/reference/modularizing-the-monolith-a-real-world-experience.md b/docs/reference/modularizing-the-monolith-a-real-world-experience.md new file mode 100644 index 0000000..8b61687 --- /dev/null +++ b/docs/reference/modularizing-the-monolith-a-real-world-experience.md @@ -0,0 +1,440 @@ +# Modularizing the monolith: a real-world experience + +> Saved from https://mateusguimaraes.com/posts/modularizing-the-monolith-a-real-world-experience +> Author: Mateus Guimarães · Date shown on page: Dec 04, 2027 · Saved: 2026-09-01 + +Microservices have lots of benefits (and downsides), but one common reason people move away from monoliths is that the project became a big ball of mud, and the thinking that cutting it into several services will fix it (it probably won’t — you’ll have multiple smaller balls of mud). + +A nice middle-ground is modular monoliths. Well, what does that mean? It means that you divide your monolith into several mini-apps. I might use the word “Domain”, from DDD, erroneously at this article sometimes. + +It is common for us to group things by `Type`. For instance, this would be a common structure for a Laravel project: + +``` +├── Actions +│   ├── RefundPayment.php +│   ├── CancelCustomerSubscription.php +│   ├── DeleteSite.php +├── QueryBuilders +│   └── SiteQueryBuilder.php +├── Console +│   ├── Commands +│   └── Kernel.php +├── Contracts +│   ├── HasSites.php +│   ├── MakesHttpRequests.php +├── Enums +│   ├── SiteType.php +│   ├── PaymentStatus.php +│   └── UserRole.php +├── Events +│   ├── CustomerCanceledSubscription.php +│   └── CustomerMadePayment.php +│   └── SiteCreated.php +├── Exceptions +│   ├── CouldNotUpdateTwilioDetailsException.php +│   └── Handler.php +├── Facades +│   └── Twilio.php +├── Http +│   ├── Controllers +│   │   ├── Api +│   │   │   ├── SiteController.php +│   │   ├── Controller.php +│   │   ├── DashboardController.php +│   │   ├── InvoiceController.php +│   ├── Kernel.php +│   ├── Livewire +│   ├── Middleware +│   ├── Requests +│   │   ├── StartTrialRequest.php +│   │   └── StoreSiteRequest.php +│   ├── Resources +│   │   ├── UserResource.php +│   │   ├── SiteResource.php +│   │   ├── InvoiceResource.php +├── Jobs +│   ├── UpdateSiteStats.php +│   ├── RefreshInvoice.php +│   ├── UnlockTwilioAccounts.php +├── Listeners +│   ├── AddCustomerToCustomersEmailList.php +│   ├── AddCustomerToTrialEmailList.php +│   ├── RemoveCustomerFromMembersLists.php +│   ├── UpdateSiteDnsRecords.php +│   ├── SendPaymentMadeEmail.php +│   ├── SendSubscriptionCanceledEmail.php +├── Mail +│   ├── PaymentFailedEmail.php +│   ├── PaymentMadeEmail.php +│   ├── RebillNotice.php +├── Models +│   ├── Customer.php +│   ├── Invoice.php +│   ├── Payment.php +├── Notifications +├── Observers +│   ├── SiteObserver.php +├── Policies +├── Providers +├── Rules +├── Services +├── Support +├── Traits +│   ├── HasLicenses.php +│   └── MemoizesValues.php +``` + +I actually simplified this a lot, but you get the idea — as the app grows, this starts to get very confusing. +For instance, what do `UpdateSiteStats` and `RefreshInvoice` have in common? Nothing, except that they’re both queued jobs — they have the same `Type`. That’s the reason they’re on the same folder — but they don’t share a lot. In fact, they don’t share anything — they belong to two different modules. + +With that said, is this organization bad? Not at all. This will work for many — I’d say most — applications, but it’s possible it’ll make things too confusing if your application is too dense. That’s where `modular monoliths` come into play. + +A simple way to think of it is that you split your domains — or “modules” — into several mini-applications and (may) implement boundaries between them. Keep in mind this is not a domain-driven design article — it’s simply about splitting your application. + +For example, you might have a `Billing` domain that deals with payments, invoices, etc., and also a `Sites` domain, that deals with custom sites for the clients using the service. +Those two might communicate sometimes (for instance, your application might calculate the monthly bill based on the amount of `Sites` a customer has), but overall they’re pretty independent. Here’s what it could look like based on the example I’ve shown earlier: + +``` +Modules/ +├── Billing/ +│ ├── Actions/ +│ │ └── RefundPayment.php +│ ├── Models/ +│ │ ├── Invoice.php +│ │ └── Payment.php +│ ├── Enums/ +│ │ └── PaymentStatus.php +│ ├── Events/ +│ │ └── PaymentMade.php +│ ├── Exceptions/ +│ │ └── PaymentFailedException.php +│ └── Jobs/ +│ └── RefreshInvoice.php +└── Sites/ + ├── Actions/ + │ └── DeleteSite.php + ├── Models/ + │ └── Site.php + ├── Enums/ + │ └── SiteType.php + ├── Events/ + │ └── SiteCreated.php + └── Jobs/ + └── UpdateSiteStatus.php +``` + +This is a rather simple example but you can see that now things are grouped by `Domain -> Type`. That should make things easier, right? + +Well, maybe not so much. Where are the providers, controllers, API resources, mailables, middlewares…? Should migrations still be tangled together with each other in the `database/migrations` folder? What about the views? Do they stay all together? + +Okay, so let’s take a break and think about a couple of points. + +- Laravel (and most frameworks) allow you to organize your apps however you want. You could keep the “domain stuff” inside the `Modules` folder and the “application stuff” where it usually stays. Or maybe you can treat each module as a mini-application that includes *everything* — migrations, controllers, API resources, tests, your credit card details — or you can be a bit more conservative and leave some things out. It is up to you and there is no right answer. + +- This is a very progressive approach. You don’t have to do it all at once — you can move things in small increments, in a way that doesn’t harm your team’s productivity or feature delivery speed. + +- As I said, you’re free to organize things as you want. Some people will try to force you that there’s a `correct way` of doing things, but I don’t think there is. You can follow a DDD philosophy and take inspiration from other well-defined architectures, but it is **very unlikely** that you’ll be able to follow anything by the book, especially when working in existing, large projects. + +Before we move on, I suggest you add these two articles from Shopify engineering blog to your reading list: + +[Deconstructing the Monolith](https://shopify.engineering/deconstructing-monolith-designing-software-maximizes-developer-productivity) + +[Under Deconstruction: The State of Shopify’s Monolith — Development](https://shopify.engineering/shopify-monolith) + +Spoiler: they made several mini Rails applications inside their main monolithic application. + +# Identifying the problem + +In the case of the application I was working on, the problem was there was a huge cognitive load to work on a single feature. When I was working on `Billing`, I didn’t really care about `Sites` or `Contacts`. At least not directly. + +Besides that, navigating the app was also a bit hard since everything was tangled together. Lots of functionalities were coupled together so maintaining them was a bit harder. + +We’re using Eloquent here, so I never expected to decouple features completely — domains are always going to leak through Eloquent models. + +The goal was to limit what needed to be touched whenever working on a user story. Is this a `Billing` feature? Okay, cool, I want to see billing-stuff only. Sure, maybe I’ll have to take a plane to `Sitesland` to calculate how much a user should be charged, but that’s a quick trip. + +# Tackling down progressively + +I did not want to spend a lot of time moving things around, especially because, well, we needed to deliver and fix features. + +I started to slowly group things by domain, not caring much about the application layer. + +This is not especially hard — it’s just moving things around and changing namespaces — the problem comes down to coupling. More often than not some components depended and maybe were even used by more than one `Thing`. Some methods had weird signatures, `arrays` and destructured data were being passed around, etc. + +I want to add one more thing: we had a robust test suite, so that helped make things easier immensely. + +I ended up making weekly pull requests to “extract” those modules out of the big ball of mud. I was looking for a couple of things: + +- To extract the domain logic + +- To refactor things to accept DTOs instead of mysterious data or Eloquent models + +- Making sure tests pass after all this + +That didn’t give me well-defined boundaries — communication with the **extracted** module was better, yes — but that module would still communicate with the big ball of mud. So it’s something progressive — tough to do at once unless you halt any development. + +At this point, the app structure would’ve looked like this: + +``` +app/ +├── Actions/ +│ ├── CancelCustomerSubscription.php +│ └── … +├── Console/ +│ └── … +├── Contracts +├── Enums/ +│ ├── SiteType.php +│ └── … +├── Events +├── Exceptions +├── Http/ +│ ├── Controllers/ +│ │ ├── InvoiceController.php +│ │ └── … +│ ├── Middleware +│ ├── Requests +│ └── Resources/ +│ ├── InvoiceResource.php +│ └── … +└── … +Modules/ +└── Billing/ + ├── Actions/ + │ ├── RefundPayment.php + │ └── CreateInvoice.php + ├── Models/ + │ ├── Invoice.php + │ ├── InvoiceItem.php + │ └── Payment.php + ├── Contracts/ + │ └── BillableContract.php + ├── Enums/ + │ └── PaymentStatus.php + ├── Events/ + │ └── PaymentMade.php + ├── Exceptions + ├── Jobs + └── DataTransferObjects/ + ├── PendingInvoiceDto.php + ├── InvoiceDto.php + └── PaymentDto.php +``` + +So you can see that only the `Domain` of the `Billing` module was extracted. + +Could I have extracted the application layer? Yes, but that would’ve made the pull request bigger and more error-prone. Small and progressive was what I was looking for. + +You’ll notice that a couple of DTOs were introduced — that made interacting with the `Billing` domain a bit easier. For instance, a job that calculates , makes a payment and generates an invoice could look like this: + +```php +sites; +  + $pendingInvoice = PendingInvoice::make( + userId: $user->id, + items: $sites + ); +  + try { + $payment = $this->chargeUserAction->handle($pendingInvoice); + } catch (PaymentFailedException $exception) { + return; + } +  + $this->createInvoiceAction->handle($pendingInvoice, $payment); + } +} +  +class PendingInvoice +{ + /** + /* @param int $userId + /* @param BillableContract[] $items + */ + public function __construct( + public int $userId, + public array $items + ) {} +  + public function total(): float + { + return array_reduce($this->items, fn (BillableContract $billable) => $billable->total()); + } +} +  +class ChargeUserAction +{ + public function __construct( + protected PendingInvoice $pendingInvoice, + protected PaymentProviderContract $paymentProvider + ) {} +  + public function handle() + { + $providerPayment = $this->paymentProvider->charge($this->pendingInvoice->userId, $this->pendingInvoice->total()); +  + // some logic + return $payment; + } +} +  +class CreateInvoiceAction +{ + public function __construct( + protected PendingInvoice $pendingInvoice, + protected Payment $payment + ) {} +  + public function handle(): Invoice + { + $invoice = Invoice::create([ + 'total' => $pendingInvoice->total(), + 'payment_id' => $this->payment->id, + 'user_id' => $pendingInvoice->userId + ]); +  + $invoice->attachItems($pendingInvoice->items); +  + return $invoice; + } +} +``` + +So as you can see you end up with small, composable actions and structured ways to pass data around. The modules are not **isolated** by any means, but things have their place. + +For instance, `User` is not a part of the `Billing` module, and it is still being used — but everything else —, `payments`, `invoices` and the actions, are. + +The actions still return Eloquent models, Eloquent relationships are still used and that’s okay. If you want to leverage Eloquent, you have to be okay with things like these. + +As you can see, as you migrate each `Thing`to it’s `Module` (or Domain), you still have to refactor some things outside of it, maybe create some actions, but overall it isn’t an extremely hard process. +The key thing here is that you can do this as you go — you’re not required, by any means, to rearchitect the entire application at once. In fact, you don’t even have to move the entire `Domain Layer`(like we did on the example) at once. + +## Moving the rest of the module + +We basically moved the `Domain Layer` of `Billing`. We still have controllers, requests, API resources, etc. on the `app`folder. and `Migrations` on the `database/migrations` folder. + +Moving things such as `Policies`, `Views` `Routing`, and `Migrations` to other places is not as straightforward as moving the domain layer — that’s because you have to instruct Laravel from where it should load those from. + +To do that we can use `Service Providers`. Let’s add one inside `Modules/Billing/Providers/BillingServiceProvider.php` + +```php +loadRoutesFrom(__DIR__ . '../Routes/web.php'); + $this->loadMigrationsFrom(__DIR__ . '../Database/Migrations'); + $this->loadViewsFrom(__DIR__ . '../Views', 'billing'); + } +} +``` + +That does a couple of things: + +- Loads a route file from `Modules/Billing/Routes/web.php` + +- Loads migrations from `Modules/Billing/Database/Migrations` + +- Loads views from `Modules/Billing/Views`using the `billing` namespace — which means you’d call the view `app.blade.php` as `billing::app` + +If you take a look at the last module tree, you’ll see that the `Billing` folder contained, basically, domain code, but it now has other things. + +Since we added a routes file, we might as well move controllers, API resources, requests, middleware, etc — the `Application Layer`. +That’d leave us with a bunch of things and it’s starting to get confused once again. That’s a good time to make things even more specific — separate what is `Domain`, `Infrastructure`, and `Application` code. +That gives us something like this: + +``` +Modules/ +└── Billing/ + ├── Domain/ + │ ├── Actions/ + │ │ ├── RefundPayment.php + │ │ └── CreateInvoice.php + │ ├── Models/ + │ │ ├── Invoice.php + │ │ ├── InvoiceItem.php + │ │ └── Payment.php + │ ├── Contracts/ + │ │ └── BillableContract.php + │ ├── Enums/ + │ │ └── PaymentStatus.php + │ ├── Events/ + │ │ └── PaymentMade.php + │ ├── Exceptions + │ ├── Jobs + │ └── DataTransferObjects/ + │ ├── PendingInvoiceDto.php + │ ├── InvoiceDto.php + │ └── PaymentDto.php + ├── Application/ + │ ├── Http/ + │ │ ├── Controllers/ + │ │ │ └── InvoiceController.php + │ │ ├── Middleware + │ │ ├── Requests + │ │ └── Resources/ + │ │ └── InvoiceResource.php + │ └── Views/ + │ └── invoices/ + │ └── index.blade.php + ├── Routes/ + │ └── web.php + └── Database/ + ├── Migrations + ├── Factories + └── Seeders +``` + +You could also add an `Infrastructure` directory, but for simplicity I only moved the largest ones. +**NOTE**: This is not DDD: this is just us splitting an application into modules. + +As you can see, things are now very well segregated. If you needed to work on the `Billing` piece of the application, you shouldn’t have to leave that base directory. + +“Oh but I need to charge users for `Contacts` now”. What do I do? + +Well, you can see that the jobs accept `PendingInvoiceDto`, which accepts an array of `BillableContract` . Just include those contacts in that array and that’s it — you don’t really have to touch any code inside `Billing`. +Need to add some VAT calculation? Okay, you can do that inside `Billing-World`. No need to look at anything else. + +## Going further + +We already moved a lot of things, and the `app` folder no longer contains anything directly related to `Billing`. The only missing piece is… tests. The `Billing` tests are still tangled with the rest of our application. + +You’re not someone who follows the rules, and you want to have the module’s tests inside the module’s space. How can you do that? +It is surprisingly simple. We can just add a `Modules/Billing/tests` directory and instruct PHPUnit to look there. + +``` + + + + + ./tests/Unit + + + ./tests/Feature + + + +``` + +Once you move everything over you can kill the root `tests`folder and then delete it from `phpunit.xml`. diff --git a/docs/reference/module-boundaries.md b/docs/reference/module-boundaries.md new file mode 100644 index 0000000..1b8ad3b --- /dev/null +++ b/docs/reference/module-boundaries.md @@ -0,0 +1,9 @@ +# Module boundaries + +Notes on applying modular-monolith ideas (see [Modularizing the monolith](modularizing-the-monolith-a-real-world-experience.md)) to this application. + +- Public and internal are real boundaries. Internal functionality already has its own routes, middleware, and controllers, and should stay separated from the public-facing application. +- Account settings is not a meaningful module boundary. It is mostly a couple of controllers grouped under the same URL prefix and does not need its own domain/module. +- Idea is shared across the application. Public features, internal features, comments, feedback, and other parts of the app all depend on it, so it should not belong to a single feature module. +- Shared code should stay shared rather than being duplicated just to make module folders look self-contained. +- Prefer boundaries that reflect how the application actually behaves over forcing every controller or model into a module. diff --git a/resources/js/Components/MergeIdeaForm.vue b/resources/js/Components/MergeIdeaForm.vue new file mode 100644 index 0000000..0f882db --- /dev/null +++ b/resources/js/Components/MergeIdeaForm.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/Components/ReactionBar.vue b/resources/js/Components/ReactionBar.vue index 00ab246..04713ac 100644 --- a/resources/js/Components/ReactionBar.vue +++ b/resources/js/Components/ReactionBar.vue @@ -1,7 +1,7 @@