SpaceNode
SpaceNode has no built-in database layer — but integrates seamlessly with Mongoose (MongoDB ODM). Mongoose uses a global connection, so you connect once and all models work automatically through SpaceNode's DI system.
npm install mongoose
That's the only dependency you need.
Call mongoose.connect() before createApp(). This ensures the connection is ready when modules load their models.
Mongoose uses a global connection — connect once and all models work automatically:
// app.js
import mongoose from 'mongoose'
import { createApp } from 'spacenode'
await mongoose.connect(process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/myapp')
const app = await createApp()
app.listen(3000)
Controllers use models via services — no db reference needed:
// In controller:
export async function stats({ send }, { userService }) {
send(await userService.count())
}
config.dbPass the connection to createApp() — it becomes available as request.db in every handler:
// app.js
import mongoose from 'mongoose'
import { createApp } from 'spacenode'
await mongoose.connect(process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/myapp')
const app = await createApp({ db: mongoose.connection })
app.listen(3000)
Use request.db for direct collection access without models:
// In controller:
export async function stats({ db, send }) {
const count = await db.collection('users').countDocuments()
send({ count })
}
config.db accepts any database reference — Mongoose connection, Knex instance, pg pool, Prisma client, etc. It's simply passed through to request.db without any processing.
createApp()? When createApp() loads modules, it imports *.service.js files, which import models. Models call mongoose.model(), which requires an active connection. If you connect after, you'll get errors.
| Step | What happens |
|---|---|
mongoose.connect() | Opens TCP connection to MongoDB, authenticates |
createApp() | Auto-discovers modules → imports *.service.js → services import models → models register on the active connection |
app.listen() | Starts HTTP server. Controllers use services → services use models → models use the connection |
Models are defined in *.model.js files inside your module folder. This is a convention (not required by SpaceNode), but it keeps things organized.
// modules/user/user.model.js
import mongoose from 'mongoose'
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true, maxlength: 100 },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
}, {
timestamps: true, // adds createdAt, updatedAt
versionKey: false, // removes __v field
})
export const User = mongoose.model('User', userSchema)
// modules/post/post.model.js
import mongoose from 'mongoose'
const postSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
content: { type: String, required: true, maxlength: 10000 },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
tags: [{ type: String, trim: true, lowercase: true }],
published: { type: Boolean, default: false },
}, {
timestamps: true,
versionKey: false,
})
export const Post = mongoose.model('Post', postSchema)
| Type | Example | Notes |
|---|---|---|
String | { type: String, required: true } | trim, lowercase, maxlength, enum, match (regex) |
Number | { type: Number, min: 0, max: 100 } | min, max, default |
Boolean | { type: Boolean, default: false } | |
Date | { type: Date, default: Date.now } | Use timestamps: true for auto createdAt/updatedAt |
ObjectId | { type: Schema.Types.ObjectId, ref: 'User' } | Reference to another model, use with .populate() |
[String] | tags: [{ type: String }] | Array of strings |
Mixed | { type: Schema.Types.Mixed } | Any type (no validation) |
Services import models directly and implement CRUD operations. SpaceNode auto-discovers *.service.js files and injects them into controllers via DI.
// modules/user/user.service.js
import { User } from './user.model.js'
export const userService = {
async list() {
return User.find().sort({ createdAt: -1 }).lean()
},
async get(id) {
return User.findById(id).lean()
},
async create(data) {
const user = await User.create(data)
return user.toObject()
},
async update(id, data) {
return User.findByIdAndUpdate(id, data, {
new: true, // return updated document
runValidators: true, // validate on update
}).lean()
},
async remove(id) {
const result = await User.findByIdAndDelete(id)
return !!result
},
}
.lean() returns plain JavaScript objects instead of Mongoose documents. This is faster and works perfectly with SpaceNode's send() — which serializes the response to JSON.
// modules/post/post.service.js
import { Post } from './post.model.js'
export const postService = {
async list(query = {}) {
const filter = {}
if (query.author) filter.author = query.author
if (query.tag) filter.tags = query.tag
return Post.find(filter)
.populate('author', 'name email') // join User, pick fields
.sort({ createdAt: -1 })
.lean()
},
async get(id) {
return Post.findById(id)
.populate('author', 'name email')
.lean()
},
async create(data) {
const post = await Post.create(data)
return post.toObject()
},
}
Controllers don't know about Mongoose at all — they receive services via the second argument (DI). This keeps controllers clean and testable.
// modules/user/user.controller.js
export async function list({ send }, { userService }) {
send(await userService.list())
}
export async function get({ params, check, send }, { userService }) {
const user = await userService.get(params.id)
check(user, 404, 'User not found')
send(user)
}
export async function create({ body, send }, { userService }) {
const user = await userService.create(body)
send(201, user)
}
export async function update({ params, body, check, send }, { userService }) {
const user = await userService.update(params.id, body)
check(user, 404, 'User not found')
send(user)
}
export async function remove({ params, send }, { userService }) {
const deleted = await userService.remove(params.id)
if (!deleted) return send(404, { error: 'User not found' })
send(204)
}
Standard SpaceNode module config — routes map to controller functions:
// modules/user/module.js
export default {
name: 'user',
prefix: '/users',
routes: [
['GET', '/', 'list'],
['GET', '/:id', 'get'],
['POST', '/', 'create'],
['PUT', '/:id', 'update'],
['DELETE', '/:id', 'remove'],
],
}
Here's the full picture — how all pieces fit together:
my-app/
app.js ← mongoose.connect() + createApp()
package.json
modules/
user/
module.js ← route definitions
user.model.js ← Mongoose schema & model
user.service.js ← CRUD logic (imports model)
user.controller.js ← handlers (uses service via DI)
post/
module.js
post.model.js
post.service.js
post.controller.js
Request → Controller → Service → Model → MongoDB
↑ ↑ ↑
DI import mongoose.model()
(auto-injected) (direct) (global connection)
SpaceNode's global error handler catches Mongoose errors automatically. Common scenarios:
| Error | Cause | Response |
|---|---|---|
ValidationError | Missing required field, invalid enum, failed maxlength | 400 — message from Mongoose |
CastError | Invalid ObjectId format (e.g. GET /users/abc) | 500 — generic error |
MongoServerError 11000 | Duplicate key (e.g. duplicate email with unique: true) | 500 — generic error |
For better error responses, handle specific cases in your service or use a pipeline pipe:
// In service — handle duplicate key
async create(data) {
try {
const user = await User.create(data)
return user.toObject()
} catch (err) {
if (err.code === 11000) {
throw new HttpError(409, 'Email already exists')
}
throw err
}
}
.lean() for reads.lean() skips Mongoose document instantiation — returns plain objects, 2-5x faster for read operations. Perfect for API responses.
timestamps: trueAdds createdAt and updatedAt fields automatically. No manual date handling needed.
// Local development
MONGO_URI=mongodb://127.0.0.1:27017/myapp
// MongoDB Atlas (production)
MONGO_URI=mongodb+srv://user:pass@cluster.mongodb.net/myapp
process.on('SIGTERM', async () => {
await mongoose.connection.close()
console.log('MongoDB disconnected')
process.exit(0)
})
Services are shared globally via DI. A postService can be used in a user controller:
// modules/user/user.controller.js
export async function posts({ params, send }, { postService }) {
// Get all posts by this user — postService from another module
send(await postService.list({ author: params.id }))
}
See the complete working example with User and Post models: examples/mongoose-api/