When a Blockchain Actually Makes Sense
A blockchain is a database replicated across a network of computers. Once a record is added, it becomes extremely difficult to alter, and the network continuously verifies that every copy stays identical. This design contrasts sharply with traditional databases, where hacks, errors, and downtime are realistic risks. A decentralized record cannot be corrupted by a single actor or lost to a server failure, offering both a seamless current state and a full historical trail.
Applications built on this model are called dApps (decentralized applications). While they are most visible in fintech, the use cases extend well beyond finance. Health-tech platforms use blockchain to safeguard sensitive patient data, IoT companies use distributed ledgers for encrypted device communication, and music streaming services rely on it for transparent royalty distribution.
Consider blockchain when you need any of the following:
- Secure digital relationships. Smart contracts on Ethereum are well suited for automating agreements. Payments can be released automatically once both parties confirm their conditions have been met, without a central authority.
- Removal of intermediaries. Platforms like Airbnb and Uber act as gatekeepers, taking a cut from each transaction. Blockchain can connect providers and customers directly, as TUI has demonstrated in the travel industry.
- Trust through shared records. Simple two-party transactions may work fine on a standard database, but complex relationships with multiple stakeholders benefit from the reduced friction and inherent security of a decentralized ledger. The University of Melbourne, for example, stores its academic records on a blockchain to streamline verification of credentials.
- History alongside fresh data. For systems where information constantly changes, blockchain preserves every previous state. E-commerce applications benefit here because transactions become safer and faster, with lower overhead for payment processing and inventory management.
- Global accessibility. Decentralization removes geographic limitations. A store in Europe can accept payment from a customer in Africa without needing a regional payment gateway or a middleman to process the exchange.
- Technology neutrality. Blockchain works with any stack. A Python developer need not learn Node, or vice versa. For simpler tasks, front-end applications in Vue or React can use a blockchain as the sole database, backed by tools like web3 for direct chain interaction.
Known Trade-offs
Blockchain is not always the right answer. Before adopting it, weigh these disadvantages:
- Speed. Consensus mechanisms require substantial computational work, slowing down processing. For millisecond-level transactional speed, a centralized database is still the better tool.
- Immutability. The inability to erase data preserves history but also conflicts with privacy rights. Once a user's data is written to the chain, removing their traces is impossible.
- Expertise required. Designing and maintaining a blockchain system is complex. Trained specialists are rare because the learning curve is steep.
- Interoperability. Competing blockchain networks each solve the distributed ledger problem differently. Connecting systems across chains remains difficult.
- Legacy integration. For companies on older architectures, moving to blockchain typically requires a full system overhaul, which is impractical for many existing operations.
Blockchain technology is still maturing, so several of these limitations may eventually be resolved. Bitcoin is the most famous blockchain, but Ethereum is the key platform for smart contracts — the driver behind most new decentralized products.
Anatomy of a dApp Backend
To see how this works in practice, we can build an Ethereum blockchain and connect it to a standard Node.js API. The result is a "decentralized application API," and the process reveals the architecture common to most dApps.
In a typical flow, a user interacts with a web or mobile front end. That front end calls backend APIs, which in turn interact with smart contracts through public nodes. The backend may itself run the necessary Node.js client or connect to a node externally. Along the way, developers must decide between fully decentralized and semi-decentralized designs, which parts of the system truly need a chain, and how to securely manage private keys.
With that context, the next step is to set up the Ethereum development environment and begin writing the smart contract that will back the API.
Ethereum API Tooling Primer
This tutorial builds the backend for a decentralized music store: users authenticate by email, upload audio files that get stored on IPFS, and the IPFS address is written to the Ethereum blockchain. Any authenticated user can then retrieve music from the platform. While we use Node.js here, the same architecture applies to other languages.
Before writing code, make sure the following tools are available:
- Node.js — the runtime for the API. Install the latest LTS binary.
- Truffle Suite — a development environment, testing framework, and asset pipeline for Ethereum. It handles compiling and running smart contract scripts.
- Ganache CLI — a blockchain emulator from the Truffle team. It lets you deploy contracts and run tests without spending real funds, providing recyclable accounts for development.
- Remix — a GUI-based alternative to Ganache for deploying and testing smart contracts. Access it at
https://remix.ethereum.org. - Web3 — a collection of libraries for interacting with local or remote Ethereum nodes over HTTP, IPC, or Web Sockets.
- IPFS — the InterPlanetary File System, a peer-to-peer protocol for distributed file storage, commonly used in dApp development.
Scaffolding The Node.js Backend
Start by initializing an npm project. Create a folder named blockchain-music, open a terminal in it, and run:
$ npm init -y && touch server.js routes.js
This creates a package.json file. Then create server.js and routes.js files; the latter will hold all API endpoint definitions.
Install the dependencies needed for the build:
- Express.js
- @truffle/contract
- Truffle.js
- web3.js
- dotenv
- short-id
- MongoDB driver
- nodemon
Install Truffle.js globally so it is available across all local projects. You can install everything at once with:
$ npm install nodemon truffle-contract dotenv mongodb shortid express web3 --save && npm install truffle -g
The --save flag records package names in package.json, and -g installs a package globally.
Create an .env file with touch .env to store the MongoDB connection URI. The dotenv package loads these variables into the Node.js process environment. Never commit the .env file to public repositories, as it may contain passwords and private data.
Next, add build and development scripts to package.json. The default generated file looks like this:
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"socket.io": "^2.3.0",
"truffle-contract": "^4.0.31",
"web3": "^1.3.0"
}
}
Add a start script that runs the server with nodemon (so changes restart the server automatically) and a build script that uses node directly:
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon server.js",
"build": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"socket.io": "^2.3.0",
"truffle-contract": "^4.0.31",
"web3": "^1.3.0"
}
}
Initialize Truffle in the project folder using the globally installed package:
$ truffle init
Now open server.js and set up the application skeleton. The example below keeps the server clean by moving routes and business logic to separate files:
require('dotenv').config();
const express= require('express')
const app =express()
const routes = require('./routes')
const Web3 = require('web3');
const mongodb = require('mongodb').MongoClient
const contract = require('truffle-contract');
app.use(express.json())
mongodb.connect(process.env.DB,{ useUnifiedTopology: true },(err,client)=>{
const db =client.db('Cluster0')
//home
routes(app,db)
app.listen(process.env.PORT || 8082, () => {
console.log('listening on port 8082');
})
})
This file imports needed libraries with require, enables JSON in the API via app.use, connects to MongoDB using the URI from the environment, and selects the Cluster0 database. The routes function imported from routes.js is then invoked, and the server listens on port 8082.
Five API Endpoints
The API exposes five endpoints for users:
- Registration — registers a user by email. To keep the tutorial focused, we skip password hashing and security, since the goal is simply user identification.
POST /register Requirements: email - Login — authenticates a user by email.
POST /login Requirements: email - Upload — accepts music file data. The frontend converts MP3/WAV files to an audio buffer and sends that buffer to the API.
POST /upload Requirements: name, title of music, music file buffer or URL stored - Access — provides the music buffer to any registered user who requests it, logging who accessed it.
GET /access/{email}/{id} Requirements: email, id - Library — returns the full music library to a registered user.
GET /access/{email} Requirements: email
Write these route functions in routes.js, using MongoDB operations for storage and retrieval. Make sure the route function is exported at the end of the file so it can be imported elsewhere:
const shortid = require('short-id')
function routes(app, db){
app.post('/register', (req,res)=>{
let email = req.body.email
let idd = shortid.generate()
if(email){
db.findOne({email}, (err, doc)=>{
if(doc){
res.status(400).json({"status":"Failed", "reason":"Already registered"})
}else{
db.insertOne({email})
res.json({"status":"success","id":idd})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.post('/login', (req,res)=>{
let email = req.body.email
if(email){
db.findOne({email}, (err, doc)=>{
if(doc){
res.json({"status":"success","id":doc.id})
}else{
res.status(400).json({"status":"Failed", "reason":"Not recognised"})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.post('/upload', (req,res)=>{
let buffer = req.body.buffer
let name = req.body.name
let title = req.body.title
if(buffer && title){
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.get('/access/:email/:id', (req,res)=>{
if(req.params.id && req.params.email){
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
}
module.exports = routes
Inside the exported route function, the app and db parameters are used to define endpoint functions. The four standard HTTP operations form the basis:
get— read recordspost— create recordsput— update recordsdelete— delete records
This example uses post for registration, login, and upload, and get for data access. The database operations are visible in the register route (db.createa) and login route (db.findOne). All operations target a named collection specified with db.collection. For a deeper reference on MongoDB shell operations, see the mongo Shell Methods documentation.
Writing a Solidity Contract
Smart contracts on Ethereum are written in Solidity, a language with JavaScript-like syntax. For this project, we store the music file’s IPFS address on-chain rather than the file itself, which keeps transaction costs low and preserves the integrity of the stored reference.
Create a new file Inbox.sol in the contracts folder. Inside it, define a contract named Inbox with structures, events, and modifiers that will be used by an ipfsInbox object. After initializing the contract via its constructor, define three functions: sendIPFS, where the user supplies an identifier and the hash address to be permanently recorded; getHash, which returns the stored hash for a given identifier; and checkInbox, which is included primarily for testing results.
pragma solidity ^0.5.0;
contract Inbox{
//Structure
mapping (string=>string) public ipfsInbox;
//Events
event ipfsSent(string _ipfsHash, string _address);
event inboxResponse(string response);
//Modifiers
modifier notFull (string memory _string) {
bytes memory stringTest = bytes(_string);
require(stringTest.length==0);
_;
}
// An empty constructor that creates an instance of the conteact
constructor() public{}
//takes in receiver's address and IPFS hash. Places the IPFSadress in the receiver's inbox
function sendIPFS(string memory _address, string memory _ipfsHash) notFull(ipfsInbox[_address]) public{
ipfsInbox[_address] = _ipfsHash;
emit ipfsSent(_ipfsHash, _address);
}
//retrieves hash
function getHash(string memory _address) public view returns(string memory) {
string memory ipfs_hash=ipfsInbox[_address];
//emit inboxResponse(ipfs_hash);
return ipfs_hash;
}
}
Testing the contract is easiest in the Remix IDE, where you can paste, run, and debug the Solidity code interactively. Once the logic is verified there, move on to a local compilation and migration with Truffle.
Compiling and Migrating Locally
Start by installing the blockchain emulator ganache-cli in the same directory:
$ npm install ganache-cli -g
Keep that terminal running and open a second one for the next steps. The emulator provides a local Ethereum node for contract deployment and testing during development.
Next, configure Truffle. In the truffle.js (Linux/Mac) or truffle-config.js (Windows) file, add the build folder path where compiled JSON artifacts will be placed and specify the network to use for migration:
const path = require("path");
module.exports = {
// to customize your Truffle configuration!
contracts_build_directory: path.join(__dirname, "/build"),
networks: {
development: {
host: "127.0.0.1",
port: 8545,
network_id: "*" //Match any network id
}
}
};
In the migrations folder, create 2_migrate_inbox.js. This file imports the compiled contract and uses Truffle’s deployer function to deploy it automatically during migration:
var IPFSInbox = artifacts.require("./Inbox.sol");
module.exports = function(deployer) {
deployer.deploy(IPFSInbox);
};
Compile the contracts with:
$ truffle compile
A successful compilation prints messages that look like this:
> Compiled successfully using:
- solc: 0.5.16+commit.9c3226ce.Emscripten.clang
Execute the migration with:
$ truffle migrate
A completed migration confirms the contract is deployed to the local emulator. Verify this from the output tail:
Summary
=======
> Total deployments: 1
> Final cost: 0.00973432 ETH
Now write a test file to validate the contract behavior. Place InboxTest.js in the test folder:
const IPFSInbox = artifacts.require("./Inbox.sol")
contract("IPFSInbox", accounts =>{
it("emit event when you send a ipfs address", async()=>{
//ait for the contract
const ipfsInbox = await IPFSInbox.deployed()
//set a variable to false and get event listener
eventEmitted = false
//var event = ()
await ipfsInbox.ipfsSent((err,res)=>{
eventEmitted=true
})
//call the contract function which sends the ipfs address
await ipfsInbox.sendIPFS(accounts[1], "sampleAddress", {from: accounts[0]})
assert.equal(eventEmitted, true, "sending an IPFS request does not emit an event")
})
})
Run the test suite:
$ truffle test
The test runner executes the contract code and reports which tests passed or failed, for this tutorial expecting the following result:
$ truffle test
Using network 'development'.
Compiling your contracts...
===========================
> Compiling .\contracts\Inbox.sol
> Artifacts written to C:\Users\Ademola\AppData\Local\Temp\test--2508-n0vZ513BXz4N
> Compiled successfully using:
— solc: 0.5.16+commit.9c3226ce.Emscripten.clang
Contract: IPFSInbox
√ emit event when you send an ipfs address (373ms)
1 passing (612ms)
Connecting Web3 to the API
Front-end-only decentralized apps are common, but backend integration is necessary when incorporating third-party services or building a CMS on blockchain. Web3 is the bridge that lets Node.js talk to either a local Ethereum node (like ganache-cli) or a remote one (e.g., Ropsten or Rinkeby testnets). This tutorial sticks with the local node, but the same pattern applies to a remote deployment by storing the contract address in a .env file and referencing it during setup.
Update server.js to initialize a Web3 instance on port 8545 if one doesn’t already exist, then load the migrated contract JSON and instantiate it with truffle-contract, setting its provider to the Web3 instance provider:
require('dotenv').config();
const express= require('express')
const app =express()
const routes = require('./routes')
const Web3 = require('web3');
const mongodb = require('mongodb').MongoClient
const contract = require('truffle-contract');
const artifacts = require('./build/Inbox.json');
app.use(express.json())
if (typeof web3 !== 'undefined') {
var web3 = new Web3(web3.currentProvider)
} else {
var web3 = new Web3(new Web3.providers.HttpProvider('https://localhost:8545'))
}
const LMS = contract(artifacts)
LMS.setProvider(web3.currentProvider)
mongodb.connect(process.env.DB,{ useUnifiedTopology: true }, async(err,client)=>{
const db =client.db('Cluster0')
const accounts = await web3.eth.getAccounts();
const lms = await LMS.deployed();
//const lms = LMS.at(contract_address) for remote nodes deployed on ropsten or rinkeby
routes(app,db, lms, accounts)
app.listen(process.env.PORT || 8082, () => {
console.log('listening on port '+ (process.env.PORT || 8082));
})
})
Retrieve the accounts with web3.eth.getAccounts. In development, call the deployed function against ganache-cli to get a contract address. For a remote node, pass that address as an argument via an equivalent function call (shown as a comment in the code). Finally, pass the app, database, contract instance, and accounts to the routes function and listen for requests on port 8082.
The router follows the same pattern. Import short-id and ipfs-http-client, then initialize IPFS with the Infura backend URL ipfs.infura.io on port 5001. The interface accepts audio buffers and returns file addresses:
const shortid = require('short-id')
const IPFS =require('ipfs-api');
const ipfs = IPFS({ host: 'ipfs.infura.io',
port: 5001,protocol: 'https' });
function routes(app, dbe, lms, accounts){
let db= dbe.collection('music-users')
let music = dbe.collection('music-store')
app.post('/register', (req,res)=>{
let email = req.body.email
let idd = shortid.generate()
if(email){
db.findOne({email}, (err, doc)=>{
if(doc){
res.status(400).json({"status":"Failed", "reason":"Already registered"})
}else{
db.insertOne({email})
res.json({"status":"success","id":idd})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.post('/login', (req,res)=>{
let email = req.body.email
if(email){
db.findOne({email}, (err, doc)=>{
if(doc){
res.json({"status":"success","id":doc.id})
}else{
res.status(400).json({"status":"Failed", "reason":"Not recognised"})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.post('/upload', async (req,res)=>{
let buffer = req.body.buffer
let name = req.body.name
let title = req.body.title
let id = shortid.generate() + shortid.generate()
if(buffer && title){
let ipfsHash = await ipfs.add(buffer)
let hash = ipfsHash[0].hash
lms.sendIPFS(id, hash, {from: accounts[0]})
.then((_hash, _address)=>{
music.insertOne({id,hash, title,name})
res.json({"status":"success", id})
})
.catch(err=>{
res.status(500).json({"status":"Failed", "reason":"Upload error occured"})
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.get('/access/:email', (req,res)=>{
if(req.params.email){
db.findOne({email: req.body.email}, (err,doc)=>{
if(doc){
let data = music.find().toArray()
res.json({"status":"success", data})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
app.get('/access/:email/:id', (req,res)=>{
let id = req.params.id
if(req.params.id && req.params.email){
db.findOne({email:req.body.email},(err,doc)=>{
if(doc){
lms.getHash(id, {from: accounts[0]})
.then(async(hash)=>{
let data = await ipfs.files.get(hash)
res.json({"status":"success", data: data.content})
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
}else{
res.status(400).json({"status":"Failed", "reason":"wrong input"})
}
})
}
module.exports = routes
In the upload route, push the audio buffer to IPFS, get back its hash, generate a unique identifier, and record that hash on-chain via the sendIPFS contract method. Then store the remaining metadata in MongoDB. The download route authenticates the user by email, retrieves the IPFS hash using the identifier from the request, and streams the audio buffer back.
From Semi-dApp to Production
The completed stack is a semi-dApp, since on-chain storage is limited to content addresses while the bulk of the data stays in a centralized database. That separation provides cheaper storage and quicker lookups than a fully on-chain model, while still giving artists a direct distribution channel without royalties going to intermediaries. Music platforms built on this pattern include OPUS, Musicoin, Audius, and Resonate.
After starting the server with either npm run start or npm run build, test every endpoint from the browser or Postman. Add unit and integration tests early; they prevent regressions and lock in behavior before cloud deployment to Heroku, GCP, or AWS. The reference implementation is available on GitHub; note that the .env containing the MongoDB URI is excluded for security.
Learning Resources
- “How to Build Ethereum Dapp with React.js: Complete Step-By-Step Guide” — Gregory McCubbin
- “Ethereum + IPFS + React DApp Tutorial Pt. 1” — Alexander Ma
- “Ethereum Development with Go” — Miguel Mota
- “Create your first Ethereum dAPP with Web3 and Vue.JS (Part 1)” — Nico Vergauwen
- “Deploy a Smart Contract on Ethereum with Python, Truffle and web3py” — Gabriel Saldanha
- “How To Build A Blockchain App With Ethereum, Web3.js & Solidity Smart Contracts” — Gregory McCubbin
- “How To Build A Simple Cryptocurrency Blockchain In Node.js” — Alfrick Opidi
- “Learn about Ethereum” — Ethereum official site



