Building Khoomi - Week 2: Shop Architecture
Last week I documented listing architecture. This week: the shops that own those listings.
On Khoomi, a shop is a seller’s storefront—distinct from the user account that handles authentication. This separation lets the same person buy (via their user account) and sell (via their shop) while keeping concerns cleanly separated.
One User, One Shop
Every user can own exactly one shop. This constraint is intentional:
type Shop struct {
ID primitive.ObjectID `bson:"_id"`
UserID primitive.ObjectID `bson:"user_id"` // Owner
Name string `bson:"name"`
Username string `bson:"username"` // Unique handle
Status ShopStatus `bson:"status"`
ListingActiveCount int64 `bson:"listing_active_count"`
FollowerCount int `bson:"follower_count"`
Rating Rating `bson:"rating"`
// ...
}
Why one-to-one? Earnings are unambiguously credited. Tax reporting is straightforward. Sellers focus on building a single strong brand rather than fragmenting their efforts across multiple storefronts.
The trade-off? Users who genuinely need multiple shops (say, one for jewelry and one for clothing) must create separate accounts. For Khoomi’s target market—individual artisans—this rarely comes up. Large multi-brand sellers would need a different architecture.
Atomic Shop Creation
Creating a shop touches four collections in one transaction:
func (s *ShopServiceImpl) CreateShop(ctx context.Context, req CreateShopRequest, ownerID ObjectID) (ObjectID, error) {
shopID := primitive.NewObjectID()
callback := func(ctx mongo.SessionContext) (any, error) {
// 1. Insert the shop
_, err := s.db.Coll.Shops.InsertOne(ctx, shop)
if err != nil {
return primitive.NilObjectID, err
}
// 2. Update user to seller status
_, err = s.db.Coll.Users.UpdateOne(ctx,
bson.M{"_id": ownerID},
bson.M{"$set": bson.M{
"is_seller": true,
"seller_onboarding_level": OnboardingLevelCreatedShop,
"shop_id": shopID,
}})
if err != nil {
return primitive.NilObjectID, err
}
// 3. Create notification settings
_, err = s.CreateShopNotificationSettings(ctx, shopID)
if err != nil {
return primitive.NilObjectID, err
}
// 4. Create wallet for earnings
_, err = s.wallet.CreateWallet(ctx, shopID, ownerID)
if err != nil {
return primitive.NilObjectID, err
}
return shopID, nil
}
_, err := database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
return shopID, err
}
Four operations, one transaction. If the wallet creation fails, the user update rolls back. If the notification settings fail, the shop insert rolls back. No half-created sellers.
Why a transaction instead of eventual consistency? A user with is_seller: true but no shop would break the dashboard. A shop without a wallet can’t receive payments. These invariants must hold at all times.
The trade-off? MongoDB transactions require replica sets. Single-node development setups need extra configuration. And transactions are slower than individual writes. For shop creation (once per seller, ever), the latency is acceptable.

Shop Status Lifecycle
Shops progress through statuses that control visibility and capabilities:
const (
ShopStatusInactive = "inactive" // Just created, not visible
ShopStatusActive = "active" // Fully operational
ShopStatusPendingReview = "pendingreview" // Flagged for moderation
ShopStatusWarning = "warning" // Minor violation confirmed
ShopStatusSuspended = "suspended" // Temporarily disabled
ShopStatusBanned = "banned" // Permanently removed
)
The state machine:
INACTIVE → ACTIVE → PENDING_REVIEW → WARNING → BANNED
\→ SUSPENDED
Most shops stay active forever. inactive gives sellers time to configure branding before going live. pendingreview lets moderation investigate without immediately punishing. suspended is reversible; banned is terminal.
Why not just active and banned? Nuance. A shop selling knockoffs needs immediate suspension. A shop with unclear product descriptions needs a warning, not a ban. The intermediate states let us match response to severity.
Embedded Followers (Bounded)
The shop document embeds the 5 most recent followers:
type Shop struct {
// ...
Followers []ShopFollower `bson:"followers"` // Embedded (max 5)
FollowerCount int `bson:"follower_count"`
}
When someone follows a shop:
func (s *ShopServiceImpl) FollowShop(ctx context.Context, userID, shopID ObjectID) (ObjectID, error) {
callback := func(ctx mongo.SessionContext) (any, error) {
// Insert into followers collection (full list)
_, err := s.db.Coll.ShopFollowers.InsertOne(ctx, followerData)
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, errors.New("already following this shop")
}
return nil, err
}
// Update shop with bounded embedded array
update := bson.M{
"$push": bson.M{
"followers": bson.M{
"$each": bson.A{followerExcerpt},
"$sort": bson.M{"joined_at": -1},
"$slice": 5, // Keep only 5
"$position": 0, // Prepend (newest first)
},
},
"$inc": bson.M{"follower_count": 1},
}
_, err = s.db.Coll.Shops.UpdateOne(ctx, bson.M{"_id": shopID}, update)
return followerID, err
}
return database.ExecuteTransaction(ctx, s.db.MongoClient, callback)
}
The $slice: 5 operator caps the embedded array. The full follower list lives in shop_followers collection for pagination.
Why embed at all? The shop profile page shows “recent followers” without a join. One document fetch, one render. For a shop with 10,000 followers, loading the full list would be wasteful when we only display 5.
The trade-off? Two writes per follow (collection + embedded). The embedded data can drift if updates fail partially—though the transaction prevents this. And we’re duplicating data. For 5 followers × ~100 bytes each, the duplication is negligible.
Shipping Profiles
Each shop can have multiple shipping profiles:
type ShopShippingProfile struct {
ID ObjectID `bson:"_id"`
ShopID ObjectID `bson:"shop_id"`
Title string `bson:"title"` // "Standard Shipping"
OriginState string `bson:"origin_state"` // "Lagos"
PrimaryPrice int64 `bson:"primary_price"` // Same zone (kobo)
SecondaryPrice int64 `bson:"secondary_price"` // Different zone (kobo)
MinDeliveryDays int `bson:"min_delivery_days"`
MaxDeliveryDays int `bson:"max_delivery_days"`
IsDefault bool `bson:"is_default"`
AcceptReturns bool `bson:"accept_returns"`
ReturnPeriod int `bson:"return_period"` // Days
}
The dual pricing (PrimaryPrice vs SecondaryPrice) reflects Nigerian geography. Shipping within Lagos is cheaper than shipping from Lagos to Benue. Rather than model all 36 states individually, I use two tiers: same zone vs different zone.
Why not per-state pricing? Complexity. Most sellers ship from one location. Two tiers cover 90% of cases. A jewelry maker in Lagos charges ₦1,500 for Lagos delivery, ₦3,000 everywhere else. Good enough.
The trade-off? Sellers shipping to specific states (say, only Southwest Nigeria) need workarounds. The Destinations field exists for this, but most sellers just use “everywhere.”
Dashboard Notification Counts
The seller dashboard needs multiple counts: unread messages, new orders, pending refunds, low stock items. Six queries total. Running them sequentially would be slow.
Instead, parallel goroutines with channels:
func (s *ShopServiceImpl) GetShopNotificationCount(ctx context.Context, shopID ObjectID) (*ShopNotificationCount, error) {
result := &ShopNotificationCount{}
resultChan := make(chan countResult, 6)
// 1. Unread messages
go func() {
count := s.countUnreadMessages(ctx, shopID)
resultChan <- countResult{"unread_messages", count, nil}
}()
// 2. New orders (pending or paid)
go func() {
count := s.countNewOrders(ctx, shopID)
resultChan <- countResult{"new_orders", count, nil}
}()
// 3. Pending refunds
go func() {
count, _ := s.db.Coll.RefundRequests.CountDocuments(ctx,
bson.M{"shop_id": shopID, "status": "pending"})
resultChan <- countResult{"pending_refunds", count, nil}
}()
// 4. Low stock items (inventory <= 5)
go func() {
count, _ := s.db.Coll.Listings.CountDocuments(ctx, bson.M{
"shop_id": shopID,
"state.state": ListingStateActive,
"inventory.quantity": bson.M{"$lte": 5, "$gt": 0},
})
resultChan <- countResult{"low_stock_items", count, nil}
}()
// 5. Expiring listings (within 7 days)
go func() {
sevenDaysFromNow := time.Now().AddDate(0, 0, 7)
count, _ := s.db.Coll.Listings.CountDocuments(ctx, bson.M{
"shop_id": shopID,
"state.state": ListingStateActive,
"expires_at": bson.M{"$lte": sevenDaysFromNow},
})
resultChan <- countResult{"expiring_listings", count, nil}
}()
// 6. Unread notifications
go func() {
count, _ := s.db.Coll.ShopNotifications.CountDocuments(ctx,
bson.M{"recipient_id": shopID, "is_read": false})
resultChan <- countResult{"unread_notifications", count, nil}
}()
// Collect results
for i := 0; i < 6; i++ {
r := <-resultChan
switch r.field {
case "unread_messages":
result.UnreadMessages = r.count
case "new_orders":
result.NewOrders = r.count
// ... etc
}
}
return result, nil
}
Six queries run concurrently. Total latency is the slowest query, not the sum of all queries. On my test data, this drops dashboard load from ~600ms to ~150ms.

Why not cache these counts? They change constantly. An order comes in, the count increments. A message arrives, unread count changes. Caching would show stale data. For a seller checking their dashboard, accuracy matters more than the 150ms saved by caching.
Vacation Mode
Sellers can pause their shop without deleting anything:
type Shop struct {
// ...
IsVacation bool `bson:"is_vacation"`
VacationMessage string `bson:"vacation_message"`
}
When IsVacation is true:
- Shop is hidden from search results
- Shop page shows the vacation message
- Existing orders continue processing
- Listings remain intact
Why a flag instead of a status? Vacation is orthogonal to status. An active shop can go on vacation. A shop under warning can also go on vacation. Mixing these into one field would create a combinatorial explosion: active_vacation, warning_vacation, etc.
The trade-off? Two fields to check instead of one. Queries for “visible shops” need status: active AND is_vacation: false. Minor complexity.
Shop Announcements
Sellers can post announcements that appear at the top of their shop page:
type Shop struct {
// ...
Announcement string `bson:"announcement"`
AnnouncementModifiedAt time.Time `bson:"announcement_modified_at"`
}
Announcements are capped at 500 characters—enough for a sale notice or shipping delay warning, not enough for a newsletter. The AnnouncementModifiedAt timestamp lets the frontend show “Updated 2 days ago” without a separate query.
Why not a separate announcements collection with history? Overkill. Sellers update announcements infrequently. When they do, the old one doesn’t matter. A single field with a timestamp covers the use case.
What I’d Do Differently
The 5-follower embedding. It works, but it’s a magic number. If the design team wants to show 10 followers on the profile page, I need a migration. A configurable limit or a separate “recent followers” query might be more flexible.
The notification count queries. Six parallel queries is fast, but it’s still six round trips to MongoDB. A dedicated “shop stats” document updated via change streams would be faster. I’d pay for it with eventual consistency, but dashboard counts don’t need to be real-time accurate.
The Pattern
Same philosophy as listings: optimize for the common case.
- Most users own one shop → enforce 1:1, don’t build multi-shop support
- Most shop pages show few followers → embed 5, paginate the rest
- Most sellers ship two tiers → primary/secondary pricing, not per-state
- Most dashboard loads need all counts → parallelize, don’t waterfall
The shop architecture is stable. New features—analytics dashboards, promotional tools, bulk listing management—attach cleanly to the existing model.
Next: Week 3 - Multi-Vendor Order Architecture
—Samuel