In this tutorial, you'll build a complete Order Management System (OMS) from scratch using qqq. By the end, you'll have a fully functional enterprise application with:
- Complete CRUD operations for Orders, Customers, and Products
- User authentication with secure login
- Role-based access control (Admin, Sales, Viewer roles)
- Audit logging for compliance
- Auto-generated REST API with documentation
Let's get started.
Prerequisites
Before you begin, make sure you have:
- Java 21+ installed (
java -versionto check) - Maven 3.6+ installed (
mvn -versionto check) - Your favorite IDE (IntelliJ IDEA recommended)
- About 4-6 hours of focused time
Step 1: Create Your Project
Start by creating a new qqq project using the Maven archetype:
mvn archetype:generate \
-DarchetypeGroupId=io.qrun \
-DarchetypeArtifactId=qqq-archetype \
-DgroupId=com.example \
-DartifactId=my-oms \
-Dversion=1.0-SNAPSHOTNavigate into your new project:
cd my-omsYour project structure looks like this:
my-oms/
├── pom.xml
├── src/main/java/com/example/
│ ├── MyOmsApplication.java
│ └── metadata/
│ └── MyOmsMetaDataProvider.java
└── src/main/resources/
└── application.properties
Step 2: Define Your Data Model
The heart of any qqq application is the metadata. Let's define our tables.
Customer Table
Create src/main/java/com/example/metadata/tables/CustomerTableMetaDataProducer.java:
public class CustomerTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
public static final String NAME = "customer";
@Override
public QTableMetaData produce(QInstance qInstance)
{
return new QTableMetaData()
.withName(NAME)
.withLabel("Customer")
.withPrimaryKeyField("id")
.withFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("phone", QFieldType.STRING),
new QFieldMetaData("address", QFieldType.STRING),
new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false),
new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)
));
}
}Product Table
Create src/main/java/com/example/metadata/tables/ProductTableMetaDataProducer.java:
public class ProductTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
public static final String NAME = "product";
@Override
public QTableMetaData produce(QInstance qInstance)
{
return new QTableMetaData()
.withName(NAME)
.withLabel("Product")
.withPrimaryKeyField("id")
.withFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
new QFieldMetaData("sku", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("description", QFieldType.STRING),
new QFieldMetaData("price", QFieldType.DECIMAL).withIsRequired(true),
new QFieldMetaData("stockQuantity", QFieldType.INTEGER).withDefaultValue(0)
));
}
}Order Table
Create src/main/java/com/example/metadata/tables/OrderTableMetaDataProducer.java:
public class OrderTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
public static final String NAME = "order";
@Override
public QTableMetaData produce(QInstance qInstance)
{
return new QTableMetaData()
.withName(NAME)
.withLabel("Order")
.withPrimaryKeyField("id")
.withFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
new QFieldMetaData("orderNumber", QFieldType.STRING).withIsRequired(true),
new QFieldMetaData("customerId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("orderDate", QFieldType.DATE_TIME).withIsRequired(true),
new QFieldMetaData("status", QFieldType.STRING)
.withPossibleValueSourceName("orderStatus"),
new QFieldMetaData("subtotal", QFieldType.DECIMAL),
new QFieldMetaData("tax", QFieldType.DECIMAL),
new QFieldMetaData("total", QFieldType.DECIMAL),
new QFieldMetaData("notes", QFieldType.TEXT)
))
.withAssociation(new Association()
.withName("customer")
.withAssociatedTableName(CustomerTableMetaDataProducer.NAME)
.withJoinName("orderToCustomer"));
}
}Order Line Table
Create src/main/java/com/example/metadata/tables/OrderLineTableMetaDataProducer.java:
public class OrderLineTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
public static final String NAME = "orderLine";
@Override
public QTableMetaData produce(QInstance qInstance)
{
return new QTableMetaData()
.withName(NAME)
.withLabel("Order Line")
.withPrimaryKeyField("id")
.withFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
new QFieldMetaData("orderId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("productId", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("quantity", QFieldType.INTEGER).withIsRequired(true),
new QFieldMetaData("unitPrice", QFieldType.DECIMAL).withIsRequired(true),
new QFieldMetaData("lineTotal", QFieldType.DECIMAL)
));
}
}Step 3: Run Your Application
With just the metadata defined, you already have a working application. Run it:
mvn compile exec:java -Dexec.mainClass="com.example.MyOmsApplication"Open your browser to http://localhost:8080 and you'll see a fully functional admin UI with:
- Tables for Customers, Products, Orders, and Order Lines
- Create, Read, Update, Delete operations
- Search and filtering
- Sorting and pagination
That's the magic of qqq - your metadata drives everything.
Step 4: Add Business Logic
Let's add some business rules. Create a process to calculate order totals.
Calculate Order Total Process
Create src/main/java/com/example/processes/CalculateOrderTotalProcess.java:
public class CalculateOrderTotalProcess implements RecordCustomizerInterface
{
@Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records)
{
return calculateTotals(records);
}
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records)
{
return calculateTotals(records);
}
private List<QRecord> calculateTotals(List<QRecord> records)
{
for (QRecord record : records)
{
BigDecimal subtotal = record.getValueBigDecimal("subtotal");
if (subtotal != null)
{
BigDecimal tax = subtotal.multiply(new BigDecimal("0.08"));
BigDecimal total = subtotal.add(tax);
record.setValue("tax", tax);
record.setValue("total", total);
}
}
return records;
}
}Register it in your Order table metadata:
.withRecordCustomizer(new CalculateOrderTotalProcess())Step 5: Add Order Status Workflow
Define valid status transitions for orders:
public class OrderStatusPossibleValueSource implements MetaDataProducerInterface<QPossibleValueSource>
{
@Override
public QPossibleValueSource produce(QInstance qInstance)
{
return new QPossibleValueSource()
.withName("orderStatus")
.withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(
new QPossibleValue("DRAFT", "Draft"),
new QPossibleValue("PENDING", "Pending"),
new QPossibleValue("APPROVED", "Approved"),
new QPossibleValue("SHIPPED", "Shipped"),
new QPossibleValue("DELIVERED", "Delivered"),
new QPossibleValue("CANCELLED", "Cancelled")
));
}
}Step 6: Add Authentication
qqq makes authentication simple. Add the auth module to your pom.xml:
<dependency>
<groupId>io.qrun</groupId>
<artifactId>qqq-backend-module-auth</artifactId>
<version>${qqq.version}</version>
</dependency>Configure authentication in your MetaDataProvider:
qInstance.withAuthentication(new QAuthenticationMetaData()
.withName("auth")
.withType(QAuthenticationType.TABLE_BASED)
.withUserTableName("user")
.withPasswordFieldName("passwordHash"));Now users must log in to access your application.
Step 7: Configure Role-Based Access Control
Define roles and permissions:
qInstance.withSecurityKeyType(new QSecurityKeyType()
.withName("role")
.withPossibleValueSourceName("userRole"));
// Admin role - full access
qInstance.addPermission(new QPermission()
.withName("admin")
.withTablePermissions(List.of(
new QTablePermission()
.withTableName("*")
.withAllowAll(true)
)));
// Sales role - can manage orders and customers, view products
qInstance.addPermission(new QPermission()
.withName("sales")
.withTablePermissions(List.of(
new QTablePermission()
.withTableName("order")
.withAllowInsert(true).withAllowUpdate(true).withAllowRead(true),
new QTablePermission()
.withTableName("customer")
.withAllowInsert(true).withAllowUpdate(true).withAllowRead(true),
new QTablePermission()
.withTableName("product")
.withAllowRead(true)
)));
// Viewer role - read-only access
qInstance.addPermission(new QPermission()
.withName("viewer")
.withTablePermissions(List.of(
new QTablePermission()
.withTableName("*")
.withAllowRead(true)
)));Step 8: Enable Audit Logging
For compliance and debugging, enable audit logging:
qInstance.withAuditRules(new QAuditRules()
.withAuditLevel(AuditLevel.RECORD));This automatically tracks:
- Who made changes
- When changes were made
- What changed (old vs new values)
- All in a searchable audit log table
Step 9: Your REST API
qqq automatically generates a REST API for all your tables. Access it at:
GET /api/customer # List customers
GET /api/customer/{id} # Get customer by ID
POST /api/customer # Create customer
PUT /api/customer/{id} # Update customer
DELETE /api/customer/{id} # Delete customer
The same endpoints exist for all your tables. API documentation is auto-generated at /api/docs.
Test it with curl:
# Create a customer
curl -X POST http://localhost:8080/api/customer \
-H "Content-Type: application/json" \
-d '{"name": "Acme Corp", "email": "orders@acme.com"}'
# List all orders
curl http://localhost:8080/api/orderStep 10: Deploy Your Application
Self-Hosted
Package your application as a JAR:
mvn clean package
java -jar target/my-oms-1.0-SNAPSHOT.jarqRun Managed Hosting
For zero-DevOps deployment, push to qRun:
qrun deploy --app my-omsYour app is now live with automatic scaling, backups, and updates.
What You Built
In just a few hours, you created an enterprise-grade application with:
- 4 database tables with relationships
- Full CRUD operations via UI and API
- User authentication with secure sessions
- Role-based permissions (Admin, Sales, Viewer)
- Audit logging for compliance
- Auto-generated REST API with documentation
- Production-ready deployment options
Compare this to the weeks or months it would take with traditional frameworks.
Next Steps
Now that you have a working OMS, consider:
- Add the OMS qBit - Get pre-built features like invoicing, shipping integration, and reporting
- Explore other qBits - Browse the qBits Catalog for additional functionality
- Build your own qBit - Learn to create custom qBits
- Join the community - Get help and share your work on Discord
Happy building!