When building MyTreda (an inventory system for Nigerian small businesses), I realized Western SaaS tutorials don't cover my users' reality. My traders needed WhatsApp numbers for customer contact, not just email. They run motor parts shops and cosmetics stores with industry-specific inventory needs. They face unreliable connections, so I built retry logic into my MongoDB setup. Here's what I learned making technical decisions for a market that's invisible to most developer content.
Section 1: Understanding the User (Not Just the User Story)
Who Nigerian small traders actually are:
Nigerian small business owners aren't your typical SaaS users. They're running physical stores—motor parts shops in Ladipo Market, cosmetics stores in Balogun, foodstuff businesses in Mile 12. They manage inventory with notebooks, WhatsApp messages, and mental calculations. They need software that works with their existing workflows, not against them.
Why they're different from typical SaaS customers:
Most SaaS tutorials assume:
- Reliable internet connection
- Email as primary communication
- Desktop/laptop access
- Generic "retail" or "e-commerce" categories
Nigerian traders work with:
- Spotty network connections (especially in markets)
- WhatsApp as the business lifeline
- Mobile-first (often mobile-only)
- Highly specialized inventory needs by industry
The gap between tutorials and reality:
When I started, I followed standard NestJS + MongoDB tutorials. They taught me to build "user management" and "product CRUD." But they didn't teach me:
- How to validate Nigerian phone numbers
- Why "cosmetics" and "motor parts" need different product fields
- How to handle connection drops mid-transaction
- Why WhatsApp number is more important than email
This post covers those gaps.
Section 2: WhatsApp Isn't Optional—It's Infrastructure
Why WhatsApp number is a critical business field:
In Nigeria, WhatsApp is how business happens. Customers place orders via WhatsApp. Suppliers send price lists on WhatsApp. Payment confirmations? WhatsApp. It's not a "nice to have"—it's infrastructure.
For MyTreda, I needed WhatsApp number during registration. Not as an afterthought in settings, but as a core business identity field.
Custom validation for Nigerian phone formats:
Nigerian numbers have two common formats:
-
+2348012345678(international format) -
08012345678(local format)
I needed to accept both and validate properly:
/**
* WhatsApp number (optional)
* For customer communication
*
* VALIDATION:
* - Optional field
* - If provided, should be phone number format
* - Nigerian format typically starts with +234 or 0
*/
@IsOptional()
@IsString({ message: 'WhatsApp number must be a string' })
@Matches(/^(\+234|0)[789]\d{9}$/, {
message: 'Invalid Nigerian phone number format (e.g., +2348012345678 or 08012345678)',
})
whatsappNumber?: string;
Breaking down the regex:
-
^(\+234|0)- Starts with +234 or 0 -
[789]- Next digit must be 7, 8, or 9 (Nigerian mobile prefixes) -
\d{9}- Followed by exactly 9 more digits - Total: 11 digits (local) or 14 with country code
Cultural context:
Email might be optional for some businesses. But WhatsApp? That's how they'll contact customers who owe money, confirm deliveries, and handle support. It's not just a field in the database—it's their business phone line.
Section 3: Business Types That Reflect Reality
Why generic "retail" category doesn't work:
Most SaaS apps have broad categories: "Retail," "Services," "E-commerce." But a cosmetics shop and a motor parts shop have completely different needs:
Cosmetics:
- Products have shades, sizes, expiry dates
- High SKU count (hundreds of lipstick shades)
- Fast-moving inventory
Motor parts:
- Products have car models, part numbers
- Technical specs matter (engine type, year)
- Lower turnover, higher ticket prices
Generic "retail" doesn't cut it.
The 6 business types I chose:
export declare enum BusinessType {
COSMETICS = "cosmetics",
MOTOR_PARTS = "motorParts",
BUILDING_MATERIALS = "buildingMaterials",
FASHION = "fashion",
ELECTRONICS = "electronics",
FOODSTUFF = "foodstuff"
}
export declare const BusinessTypeLabels: Record<BusinessType, string>;
export declare const getBusinessTypes: () => BusinessType[];
export declare const isValidBusinessType: (value: string) => value is BusinessType;
These aren't arbitrary. They're the six most common small business types in Lagos markets:
- Cosmetics - Beauty supplies, skincare
- Motor Parts - Auto parts, accessories
- Building Materials - Cement, tiles, plumbing
- Fashion - Clothing, shoes, fabrics
- Electronics - Phones, accessories, repairs
- Foodstuff - Groceries, provisions
How this affects future product catalog customization:
When a user selects "Motor Parts," I'll eventually customize their product form:
- Add "Compatible Car Models" field
- Add "Part Number" field
- Suggest common motor parts categories
When they select "Cosmetics":
- Add "Shade/Color" field
- Add "Expiry Date" field
- Enable batch tracking
The business type isn't just metadata—it shapes the entire product management experience.
Section 4: Designing for Unreliable Infrastructure
MongoDB connection retry logic:
Nigerian internet is... unpredictable. Even in Lagos (the most connected city), you'll get random disconnections. I couldn't assume a stable database connection.
Standard MongoDB setup:
MongooseModule.forRoot(uri) // Fails on first connection error
My setup:
retryAttempts: 3, // Retry 3 times on initial connection failure
retryDelay: 1000, // Wait 1 second between retries
Why this matters more than performance optimization:
Most tutorials obsess over:
- Query optimization
- Index strategies
- Caching layers
But if your app can't connect to the database because of a 2-second network hiccup, those optimizations are useless.
Connection event logging for debugging:
I added listeners for all connection events:
connectionFactory: (connection) => {
connection.on('connected', () => {
console.log('[MongoDB] Connected successfully');
});
connection.on('disconnected', () => {
console.warn('[MongoDB] Disconnected');
});
connection.on('error', (error: Error) => {
console.error('[MongoDB] Connection error:', error.message);
});
return connection;
}
When users report "app not working," I can check logs and see:
- Did the connection drop?
- Is it retrying?
- Did it reconnect?
This context is gold for debugging in production.
Section 5: Multi-Tenant Architecture for Small Business
One business per owner design decision:
MyTreda uses a simple multi-tenant model: One user (the business owner) creates one business. All their employees are added to that business.
This is different from enterprise SaaS where one user might manage multiple businesses. I chose simplicity over flexibility.
Why this is simpler than enterprise multi-tenant patterns:
Enterprise apps often need:
- User switches between businesses
- Different roles per business
- Complex permission inheritance
For Nigerian small businesses:
- The owner IS the business
- Employees have simple roles (admin or sales rep)
- No cross-business complexity needed (MVP)
The code:
/**
* Owner ID - The admin user who created this business
* UNIQUE: One user can only own one business (MVP constraint)
* References User._id
*/
@Prop({ required: true, unique: true, type: String, ref: 'User' })
@Transform(({ value }) => value?.toString())
ownerId!: string;
The unique: true constraint enforces this at the database level. One owner, one business. Clean.
Trade-offs: Simplicity vs. flexibility:
Pros:
- Easier to reason about
- Simpler permission model
- Faster MVP development
Cons:
- Owner can't have multiple businesses (future feature?)
- Can't transfer ownership easily
- Might need to refactor later
For MVP? Simplicity wins. I can always add multi-business support when users ask for it.
Section 6: Three-Layer Validation Strategy
Why three layers?
I validate data at three checkpoints:
- HTTP boundary (DTOs with class-validator)
- Application boundary (NestJS ValidationPipe)
- Database boundary (Mongoose schemas)
This might seem like overkill, but each layer catches different issues.
Layer 1: DTOs with class-validator (HTTP boundary)
Example from registration:
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
@IsEnum(BusinessType, { message: 'Invalid business type' })
businessType: BusinessType;
Catches: Wrong data types, format violations, missing required fields
Layer 2: NestJS ValidationPipe with whitelist/transform
new ValidationPipe({
whitelist: true, // Remove non-DTO properties
forbidNonWhitelisted: true, // Throw error on extra properties
transform: true, // Auto-convert types
transformOptions: {
enableImplicitConversion: true, // Convert query params automatically
},
});
Catches: Extra fields (security), type mismatches, malicious payloads
Layer 3: Mongoose schemas (database boundary)
@Prop({ required: true, unique: true })
email: string;
@Prop({ required: true, minlength: 8 })
password: string;
Catches: Race conditions, direct database writes, data integrity violations
Why this prevents data integrity issues:
Scenario: User tries to register with existing email
- Layer 1 checks format ✅
- Layer 2 validates DTO ✅
- Service checks if email exists ✅
- Layer 3 (MongoDB unique constraint) catches race condition ✅
If two requests come in simultaneously, layers 1-3 might not catch it, but layer 3 (database) will throw a duplicate key error. My exception filter handles it gracefully.
Defense in depth.
Section 7: Error Handling That Speaks Nigerian English
Global exception filter architecture:
I use a global exception filter to catch all errors and format them consistently. But the key is making error messages clear and actionable.
Handling Mongoose duplicate key errors:
MongoDB throws cryptic errors like:
E11000 duplicate key error collection: mytreda.users index: email_1 dup key: { email: "test@test.com" }
Users don't need to see that. They need:
"Email 'test@test.com' already exists"
The code:
/**
* Handle MongoDB duplicate key error (E11000)
* Example: Unique constraint violation (email already exists)
*/
private handleDuplicateKeyError(exception: any) {
const keyPattern = exception.keyPattern || {};
const keyValue = exception.keyValue || {};
const field = Object.keys(keyPattern)[0];
const value = keyValue[field];
const message = field
? `${this.formatFieldName(field)} '${value}' already exists`
: 'Duplicate entry detected';
return {
status: HttpStatus.CONFLICT,
message,
error: 'Conflict',
};
}
Formatting error messages users actually understand:
The formatFieldName helper converts:
-
whatsappNumber→ "Whatsapp number" -
businessName→ "Business name" -
ownerId→ "Owner id"
So errors read naturally:
- "Whatsapp number '+2348012345678' already exists"
- "Business name 'Tech Shop' already exists"
Not:
- "E11000 duplicate key error..."
Why this matters:
My users aren't developers. They're traders who might not even own laptops. Error messages need to be:
- Clear (what went wrong?)
- Actionable (how do I fix it?)
- In plain language (no tech jargon)
"Email already exists" beats "Unique constraint violation on email_1 index" every time.
Section 8: What This Means for African Tech
The importance of building for local context:
Building MyTreda taught me that the "right" technical decision depends on your users' reality:
- WhatsApp validation matters more than OAuth social login
- Retry logic matters more than sub-100ms response times
- Business type specificity matters more than generic flexibility
- Clear error messages matter more than developer-friendly stack traces
These aren't the decisions you'll find in "Build a SaaS" courses. They come from understanding your market.
Technical decisions that tutorials don't cover:
Western tutorials optimize for:
- High-speed internet
- Email-first communication
- Desktop users
- Generic business models
African apps need to optimize for:
- Unreliable connections
- Mobile-first (often mobile-only)
- WhatsApp as infrastructure
- Industry-specific workflows
Your technical stack might be the same (NestJS, MongoDB, TypeScript), but your architectural decisions will be different.
Encouragement for other African developers:
If you're building for African markets:
- Talk to your users - Don't assume Western SaaS patterns apply
- Document your decisions - Your context is valuable
- Share your learnings - The next developer needs to hear this
- Build for reality - Not for what tutorials say is "best practice"
We need more African developers sharing what works here, not just copying what works in Silicon Valley.
Conclusion
Building for Nigerian traders taught me that the hard parts aren't the framework choice or database selection—they're understanding your users' context and making technical decisions that match their reality. If you're building for markets outside the typical Silicon Valley bubble, your technical decisions will look different. And that's exactly how it should be.
MyTreda is still early (auth is done, products module is next), but every decision so far has been shaped by Nigerian business reality:
- WhatsApp isn't optional
- Network failures are normal
- Business types matter
- Error messages need to be clear
If you're building SaaS for African markets, I'd love to hear what technical decisions you've made that don't fit the typical tutorials.
Let's Connect
Building MyTreda in public and documenting the journey:
- Portfolio: tochukwu-nwosa.vercel.app
- GitHub: github.com/tochukwunwosa
- Twitter/X: @tochukwudev
- LinkedIn: linkedin.com/in/nwosa-tochukwu
What context-specific technical decisions have you made in your projects? Drop them in the comments—I'm learning too.
Top comments (0)