Database (Mongoose)

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.

Install

npm install mongoose

That's the only dependency you need.

Connect to MongoDB

Call mongoose.connect() before createApp(). This ensures the connection is ready when modules load their models.

Option 1: Global connection (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())
}

Option 2: Pass connection via config.db

Pass 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.
Why before 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.

Order of operations

StepWhat 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

Creating Models

Models are defined in *.model.js files inside your module folder. This is a convention (not required by SpaceNode), but it keeps things organized.

Basic model

// 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)

Model with relations (ObjectId ref)

// 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)

Common schema types

TypeExampleNotes
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)

Using Models in Services

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.

Service with populate (relations)

// 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()
  },
}

Using Services in Controllers

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)
}

Module Config

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'],
  ],
}

Project Structure

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

Data flow

Request → Controller → Service → Model → MongoDB
              ↑            ↑         ↑
             DI        import    mongoose.model()
         (auto-injected) (direct)  (global connection)

Handling Mongoose Errors

SpaceNode's global error handler catches Mongoose errors automatically. Common scenarios:

ErrorCauseResponse
ValidationErrorMissing required field, invalid enum, failed maxlength400 — message from Mongoose
CastErrorInvalid ObjectId format (e.g. GET /users/abc)500 — generic error
MongoServerError 11000Duplicate 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
  }
}

Tips

Use .lean() for reads

.lean() skips Mongoose document instantiation — returns plain objects, 2-5x faster for read operations. Perfect for API responses.

Use timestamps: true

Adds createdAt and updatedAt fields automatically. No manual date handling needed.

Connection string from environment

// Local development
MONGO_URI=mongodb://127.0.0.1:27017/myapp

// MongoDB Atlas (production)
MONGO_URI=mongodb+srv://user:pass@cluster.mongodb.net/myapp

Graceful shutdown

process.on('SIGTERM', async () => {
  await mongoose.connection.close()
  console.log('MongoDB disconnected')
  process.exit(0)
})

Cross-module access

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 }))
}

Full Example

See the complete working example with User and Post models: examples/mongoose-api/